using System; using UnityEngine; namespace IdleSpectator; public static partial class InterestDirector { private const float RelatedPersonalCombatCutSeconds = 3f; private const float PersonalCombatCutSeconds = 6f; 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 (candidate != _current && InterestVariety.IsEditorialReplayCooled(candidate)) { InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key); return false; } if (candidate != _current && InterestVariety.IsExactReplayCooled(candidate)) { InterestDropLog.Record("exact_replay", candidate.Label ?? candidate.Key); return false; } if (candidate != _current && InterestVariety.IsCombatRunBlocked(candidate)) { InterestDropLog.Record("combat_run_budget", candidate.Label ?? candidate.Key); return false; } if (candidate != _current && !StoryScheduler.MayUseCombatShot(candidate)) { InterestDropLog.Record( "episode_combat_budget", candidate.Label ?? candidate.Key); return false; } if (candidate != _current && !NarrativeExposureLedger.MayPresent(candidate)) { InterestDropLog.Record( "combat_exposure_budget", candidate.Label ?? candidate.Key); return false; } if (_current == null) { return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); } float now = Time.unscaledTime; // 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; } // Crisis signal tip (war / disaster / outbreak) owns the camera against Mass/Battle scraps. // Must run before crisis cut-in: a leftover war crisis can MatchCrisis(Mass) while a // small WarFront tip does not. Drop reason keeps war_theater_hold alias for harness. if (StoryPlanner.IsCrisisCameraSignal(_current) && candidate.Completion == InterestCompletionKind.CombatActive) { string holdReason = _current.Completion == InterestCompletionKind.WarFront ? "war_theater_hold" : "crisis_theater_hold"; InterestDropLog.Record( holdReason, $"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}"); return false; } // Crisis-owned tips (esp. forced epilogue_crisis) may cut without score-margin wars // against aftermath / ambient peers that outscore the closer on raw EventStrength. if (StoryPlanner.CrisisActive && StoryPlanner.MatchesCrisis(candidate) && !StoryPlanner.MatchesCrisis(_current) && IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace)) { return true; } // Live fight: hold until cold (see who wins), unless something extremely urgent cuts in. // Same-arc theater refresh / absorb still allowed. WarFront on the same kingdom // theater may reclaim the chapter (Mass → War) without waiting for combat cold. if (CombatFightIsHot(_current, now) && !IsSameStoryArc(_current, candidate) && candidate.Completion != InterestCompletionKind.CombatActive) { bool warTheaterReclaim = candidate.Completion == InterestCompletionKind.WarFront && StickyScoreboard.SharesKingdomTheater(_current, candidate); if (warTheaterReclaim) { return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false); } if (IsPersonalStoryInterrupt(candidate)) { float personalDelay = TouchesCurrentPrincipal(candidate) ? RelatedPersonalCombatCutSeconds : PersonalCombatCutSeconds; if (onCurrent >= personalDelay && IsWorthWatchingNow(candidate, now, selectingDuringGrace: false)) { InterestDropLog.Record( "combat_story_cut", $"cur={_current.Key} next={candidate.Key} on={onCurrent:0.#}"); return true; } } 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; } // Once an episode has a meaningful related development ready, let it reclaim the // camera from ordinary unrelated material after a brief visual settling beat. // Legitimate critical/payoff interruptions and live-combat holds remain protected. if (StoryScheduler.ActiveEpisode != null && onCurrent >= 0.75f) { NarrativeThread activeThread = NarrativeStoryStore.Thread(StoryScheduler.ActiveEpisode.ThreadId); NarrativeInterruptClass nextNarrativeClass = InterruptPolicy.Classify(candidate, StoryScheduler.ActiveEpisode, activeThread); NarrativeInterruptClass currentNarrativeClass = InterruptPolicy.Classify(_current, StoryScheduler.ActiveEpisode, activeThread); if (nextNarrativeClass == NarrativeInterruptClass.RelatedEscalation && !InterruptPolicy.MayInterrupt(currentNarrativeClass) && IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace)) { InterestDropLog.Record( "episode_reclaim", $"cur={_current.Key} next={candidate.Key}"); return true; } } 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; } // Prefer / MC: cut character-fill or soft-quiet linger without full score margin. // Never weakens sticky combat, war, crisis, or short-arc hard holds. if ((curFill || StoryPlanner.SoftFillQuietActive) && !sticky // Correlated short-story ownership outranks Saga Prefer/MC soft bias. // Otherwise the same MC's weak life noise can steal its own aftermath. && !StoryPlanner.OwnsCandidate(_current) && !StoryPlanner.CrisisActive && !CombatFightIsHot(_current, now) && _current.Completion != InterestCompletionKind.WarFront && !nextFill && !IsAmbientShot(candidate) && (LifeSagaRoster.TipTouchesPrefer(candidate) || LifeSagaRoster.TipTouchesMc(candidate))) { InterestDropLog.Record( "saga_ambient_cut", $"cur={_current.Key} next={candidate.Key}"); 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 / crisis hold against unrelated peers. float storyHold = Mathf.Max( StoryPlanner.ArcHoldMargin(_current, candidate), StoryPlanner.CrisisHoldMargin(_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 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; } // King / leadership deaths sit under the epic floor (king_killed=88) but must cut // live scraps - soak missed a king slaying while parked on a Duel thrash. if (IsLeadershipDeathPeer(next)) { 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; } private static bool IsPersonalStoryInterrupt(InterestCandidate candidate) { if (candidate == null || candidate.Completion == InterestCompletionKind.CombatActive || candidate.Completion == InterestCompletionKind.WarFront || InterestScoring.IsFillScore(candidate.TotalScore) || IsAmbientShot(candidate)) { return false; } if (candidate.Completion == InterestCompletionKind.HappinessGrief) { return true; } NarrativeDevelopmentKind kind = candidate.NarrativePresentation?.DevelopmentKind ?? NarrativeDevelopmentKind.Unknown; if (kind == NarrativeDevelopmentKind.BondFormed || kind == NarrativeDevelopmentKind.BondBroken || kind == NarrativeDevelopmentKind.ChildBorn || kind == NarrativeDevelopmentKind.Death || kind == NarrativeDevelopmentKind.Recovery) { return true; } string text = ((candidate.AssetId ?? "") + " " + (candidate.HappinessEffectId ?? "") + " " + (candidate.Category ?? "") + " " + (candidate.Verb ?? "")).ToLowerInvariant(); return ContainsPersonalToken( text, "child", "baby", "born", "parent", "lover", "love", "partner", "family", "grief", "mourn", "kiss", "marri"); } private static bool TouchesCurrentPrincipal(InterestCandidate candidate) { if (_current == null || candidate == null) { return false; } long currentId = _current.SubjectId != 0 ? _current.SubjectId : EventFeedUtil.SafeId(_current.FollowUnit); if (currentId == 0) { return false; } if (candidate.SubjectId == currentId || candidate.RelatedId == currentId || candidate.PairOwnerId == currentId || candidate.PairPartnerId == currentId) { return true; } for (int i = 0; i < candidate.ParticipantIds.Count; i++) { if (candidate.ParticipantIds[i] == currentId) { return true; } } return false; } private static bool ContainsPersonalToken(string text, params string[] tokens) { if (string.IsNullOrEmpty(text) || tokens == null) { return false; } for (int i = 0; i < tokens.Length; i++) { if (text.IndexOf(tokens[i], StringComparison.Ordinal) >= 0) { return true; } } return false; } public static bool HarnessProbePersonalCombatInterrupt(out string detail) { var birth = new InterestCandidate { AssetId = "add_child", Category = "Family", TotalScore = 80f }; var love = new InterestCandidate { HappinessEffectId = "fell_in_love", Category = "Relationship", TotalScore = 75f }; var routine = new InterestCandidate { HappinessEffectId = "just_slept", Category = "Activity", TotalScore = 70f }; var building = new InterestCandidate { AssetId = "building_complete", Category = "Building", TotalScore = 80f }; bool birthCuts = IsPersonalStoryInterrupt(birth); bool loveCuts = IsPersonalStoryInterrupt(love); bool routineWaits = !IsPersonalStoryInterrupt(routine); bool buildingWaits = !IsPersonalStoryInterrupt(building); bool ok = birthCuts && loveCuts && routineWaits && buildingWaits; detail = "personal=" + birthCuts + "/" + loveCuts + " waits=" + routineWaits + "/" + buildingWaits + " pass=" + ok; return ok; } /// /// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers. /// private static bool IsLeadershipDeathPeer(InterestCandidate c) { if (c == null) { return false; } string id = (c.AssetId ?? "").ToLowerInvariant(); if (id.IndexOf("king_killed", StringComparison.Ordinal) >= 0 || id.IndexOf("king_dead", StringComparison.Ordinal) >= 0 || id.IndexOf("king_died", StringComparison.Ordinal) >= 0 || id.IndexOf("leader_killed", StringComparison.Ordinal) >= 0 || id.IndexOf("favorite_killed", StringComparison.Ordinal) >= 0) { return true; } if (!string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase)) { return false; } return id.IndexOf("kill", StringComparison.Ordinal) >= 0 || id.IndexOf("dead", StringComparison.Ordinal) >= 0 || id.IndexOf("died", StringComparison.Ordinal) >= 0 || id.IndexOf("slain", StringComparison.Ordinal) >= 0; } /// /// 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 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; } // Mass → War reclaim on the same kingdom pair (never War → Mass soft-cut). if (current.Completion == InterestCompletionKind.CombatActive && next.Completion == InterestCompletionKind.WarFront && StickyScoreboard.SharesKingdomTheater(current, next)) { return true; } if (current.Completion == InterestCompletionKind.WarFront && next.Completion == InterestCompletionKind.WarFront && StickyScoreboard.SharesKingdomTheater(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; } CollectCombatActorIds(current, out long curA, out long curB); CollectCombatActorIds(next, out long nextA, out long nextB); if (curA == 0 && curB == 0 && current.TheaterLeadId == 0 && current.PairOwnerId == 0 && current.SubjectId == 0) { return false; } // Mass/Battle held on a theater lead: peer Duel with a new attack_target is noise. if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current)) { if (IsThinPairCombat(next) && SharesCombatOwnerOrLead(current, nextA, nextB)) { return true; } return false; } if (!IsThinPairCombat(next)) { return false; } // Same unordered pair with flipped Follow (Duel A vs B ↔ B vs A) is ownership noise. // Same-subject refreshes may still soft-cut; A↔B tip order must hold while both live. bool sameUnorderedPair = curA != 0 && curB != 0 && nextA != 0 && nextB != 0 && ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA)); if (sameUnorderedPair) { long curSub = current.SubjectId != 0 ? current.SubjectId : (current.PairOwnerId != 0 ? current.PairOwnerId : EventFeedUtil.SafeId(current.FollowUnit)); long nextSub = next.SubjectId != 0 ? next.SubjectId : EventFeedUtil.SafeId(next.FollowUnit); if (curSub != 0 && nextSub != 0 && curSub != nextSub) { return true; } return false; } // Shared fighter + different partner = attack_target thrash. return SharesCombatOwnerOrLead(current, nextA, nextB) || SharesCombatFighterId(current, nextA) || SharesCombatFighterId(current, nextB); } private static bool SharesCombatOwnerOrLead(InterestCandidate current, long nextA, long nextB) { if (current == null) { return false; } long lead = current.TheaterLeadId != 0 ? current.TheaterLeadId : (current.PairOwnerId != 0 ? current.PairOwnerId : current.SubjectId); if (lead == 0) { lead = EventFeedUtil.SafeId(current.FollowUnit); } return lead != 0 && (lead == nextA || lead == 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); } }