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);
}
}