- Introduce new probes for framing approval and family coalescing - Implement exact presentation replay checks to prevent duplicate events - Refactor InterestVariety to manage exact replay cooldowns effectively - Update EventPresentability to improve candidate approval logic - Enhance InterestFeeds to streamline event registration and labeling
886 lines
32 KiB
C#
886 lines
32 KiB
C#
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
|
|
&& !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;
|
|
}
|
|
|
|
/// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
|
|
private const float SoftClusterYieldSeconds = 4f;
|
|
|
|
private static bool IsSoftStickyCluster(InterestCandidate c) =>
|
|
c != null && c.Completion == InterestCompletionKind.FamilyPack;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when next is the same 1v1 pair or a multi escalate of the current scrap.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|