using System; using UnityEngine; namespace IdleSpectator; public static partial class InterestDirector { /// /// 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); } // NamedPair / living Duel ownership holds the pair. Do not gate on AssetId==live_battle // or peak alone - ambient nearby scraps polluted those flags and disabled pair hold so // the tip thrashed onto strangers while CombatStillActive stayed hot forever. bool pairOwned = ownedFocus != null && ownedFocus.isAlive() && ownedRelated != null && ownedRelated.isAlive() && IsNamedPairCombatOwnership(_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 (sleep / chores): keep NamedPair framing and do NOT fall // through to ambient fighter pick. Absorbing nearby bears into Duel - Honya vs Waf // refreshed combat hotness forever after the real pair went cold (Neen soak). if (!focusFighting && !relatedFighting) { string idleLabel = ""; Actor idleFocus = ownedFocus; Actor idleFoe = ownedRelated; int idleFighters = 2; ApplyNamedPairHold( _current, ownedFocus, ownedRelated, ref idleFocus, ref idleFoe, ref idleFighters, ref idleLabel); string priorIdle = _current.Label ?? ""; if (string.IsNullOrEmpty(idleLabel)) { idleLabel = priorIdle; } bool idleFollowChanged = _current.FollowUnit != idleFocus; bool idleLabelChanged = !string.Equals(priorIdle, idleLabel ?? "", StringComparison.Ordinal); _current.FollowUnit = idleFocus; _current.SubjectId = EventFeedUtil.SafeId(idleFocus); _current.RelatedUnit = idleFoe; _current.RelatedId = idleFoe != null ? EventFeedUtil.SafeId(idleFoe) : 0; _current.Label = idleLabel; try { _current.Position = idleFocus.current_position; } catch { // keep } bool idleNeedWatch = forceWatch || idleFollowChanged || idleLabelChanged || !HasLivingCameraFocus() || MoveCamera._focus_unit != idleFocus; if (idleNeedWatch) { CameraDirector.Watch(_current.ToInterestEvent()); } StoryPlanner.SyncActiveClimax(_current); return true; } 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()); } StoryPlanner.SyncActiveClimax(_current); 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()); } StoryPlanner.SyncActiveClimax(_current); 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 { // Keep PairOwner/Partner across escalate. Sticky multi already disables NamedPair // hold; wiping the lock made Mass collapse into Duel vs the latest attack_target. // 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; } // Living pair lock owns NamedPair tip wording across attack_target thrash. // Theater Mass/Battle may keep a fresh foe for framing; Duel must not hop partners. if (TryRestoreLockedDuelPartner( _current, curFollow, curRelated, ref best, ref foe, ref label)) { // Locked partner restored. } else if (TryHoldOwnerAfterPartnerDeath( _current, curFollow, ensemble, ref best, ref foe, ref fighters, ref label)) { // Owner still fighting after first partner died - no revolving Duel names. } 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()); } // Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo). StoryPlanner.SyncActiveClimax(_current); 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); _current.LastSeenAt = Time.unscaledTime; 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; } /// /// 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); } // Non-spectacle lead vs outnumbered spectacle challenger: steal easily so a // crowned civilian cannot keep the camera through a 1vN mage fight. bool pickSpectacleThin = !WorldActivityScanner.IsSpectaclePublic(held) && WorldActivityScanner.IsSpectaclePublic(pick) && SideCountForActor(scene, pick) > 0 && SideCountForActor(scene, pick) < SideCountForOpponent(scene, pick); if (pickSpectacleThin) { margin = Mathf.Min(margin, 8f); } 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; } /// /// First partner died while the owner is still fighting: reframe without naming the /// next attack_target (spectacle 1vN / scrap mop). Prefer collective tips when present. /// private static bool TryHoldOwnerAfterPartnerDeath( InterestCandidate scene, Actor ownedFocus, LiveEnsemble ensemble, ref Actor best, ref Actor foe, ref int fighters, ref string label) { if (scene == null || scene.PairPartnerId == 0) { return false; } // Alive-only check: corpses / destroyed refs must not keep a NamedPair tip. if (EventFeedUtil.FindAliveById(scene.PairPartnerId) != null) { return false; } Actor owner = ownedFocus != null && ownedFocus.isAlive() ? ownedFocus : EventFeedUtil.FindAliveById(scene.PairOwnerId); if (owner == null || !owner.isAlive()) { return false; } // Partner lock points at a dead unit. Collective Mass/Battle/Skirmish tips already // own the scrap - never collapse them into thin "is fighting". string prior = scene.Label ?? ""; bool alreadyCollective = prior.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) || prior.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) || prior.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase); if (alreadyCollective || HasStickyCollectiveMulti(scene) || (ensemble != null && (ensemble.ParticipantCount >= 3 || LiveEnsemble.HasOpposingCollectiveSides(ensemble) || ensemble.Scale >= EnsembleScale.Skirmish))) { return false; } // Thin NamedPair mop-up: never name a revolving Duel partner while the first // partner lock still points at a dead unit. best = owner; foe = null; fighters = Mathf.Max(1, fighters); label = EventReason.Fight(owner, null); scene.StampTheaterLead(owner); return !string.IsNullOrEmpty(label); } /// /// When the tip is (or collapses to) a NamedPair Duel, keep the locked living partner /// instead of adopting the owner's latest attack_target. /// private static bool TryRestoreLockedDuelPartner( InterestCandidate scene, Actor ownedFocus, Actor ownedRelated, ref Actor best, ref Actor foe, ref string label) { if (scene == null) { return false; } Actor partner = ownedRelated; if (partner == null || !partner.isAlive()) { if (scene.PairPartnerId != 0) { partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId); } } if (partner == null || !partner.isAlive()) { return false; } bool duelTip = !string.IsNullOrEmpty(label) && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); bool thinFight = !string.IsNullOrEmpty(label) && label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0; if (!duelTip && !thinFight) { return false; } if (foe == partner && (ownedFocus == null || best == ownedFocus || best == partner)) { return false; } Actor focus = ownedFocus != null && ownedFocus.isAlive() ? ownedFocus : (best != null && best.isAlive() ? best : scene.FollowUnit); if (focus == null || !focus.isAlive() || focus == partner) { return false; } best = focus; foe = partner; int holdFighters = 2; ApplyNamedPairHold(scene, focus, partner, ref best, ref foe, ref holdFighters, ref label); return true; } 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 thin ambient species/kingdom sticky that leaked in during cluster Refresh. // Keep 3+ enrolled camps - those are intentional escalate seeds (wire / pack growth). 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; } bool duelLabeled = !string.IsNullOrEmpty(scene.Label) && scene.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); // Ambient sticky camps must not unlock a living Duel tip (A↔B flip). // Real multi leave is owned by ShouldEscalateNamedPair / theater lead, not this hold. if (HasStickyCollectiveMulti(scene) && !duelLabeled) { return false; } 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 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) { LiveSceneStickyState sticky = scene?.Sticky; if (sticky != null) { StickyScoreboard.GetPresentationCounts(sticky, out int countA, out int countB); bool mopUp = sticky.MopUpActive || countA <= 0 || countB <= 0; int liveN = Math.Max(0, countA) + Math.Max(0, countB); int scaleN = mopUp ? Math.Max(1, liveN) : Math.Max(3, scene.CombatPeakParticipants); stickyEns = new LiveEnsemble { Kind = EnsembleKind.Combat, Scale = LiveEnsemble.ScaleForCount(scaleN), Focus = focus, Related = related, Frame = scene.CombatSideFrame, ParticipantCount = liveN }; var sideA = new EnsembleSide { Key = scene.CombatSideAKey, Display = scene.CombatSideADisplay, KingdomDisplay = scene.CombatSideAKingdom, Count = countA, Best = focus }; var sideB = new EnsembleSide { Key = scene.CombatSideBKey, Display = scene.CombatSideBDisplay, KingdomDisplay = scene.CombatSideBKingdom, Count = countB, Best = related }; bool focusOnB = false; if (focus != null) { long id = EventFeedUtil.SafeId(focus); for (int i = 0; i < sticky.SideBIds.Count; i++) { if (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; } return; } 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 sideAFallback = new EnsembleSide { Key = scene.CombatSideAKey, Display = scene.CombatSideADisplay, KingdomDisplay = scene.CombatSideAKingdom, Count = scene.CombatSideACount, Best = focus }; var sideBFallback = new EnsembleSide { Key = scene.CombatSideBKey, Display = scene.CombatSideBDisplay, KingdomDisplay = scene.CombatSideBKingdom, Count = scene.CombatSideBCount, Best = related }; stickyEns.SideA = sideAFallback; stickyEns.SideB = sideBFallback; } /// 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); } }