worldbox-observer-mod/IdleSpectator/InterestDirector.StickyMaintain.cs
DazedAnon 3941dc33e5 feat(saga): bias director onto MCs and note cross-MC beats
- Prefer/MC/cast win moderate tip gaps; combat follow and ambient cut-in follow roster
- Dual-light rail when tip touches multiple MCs; record MC↔MC crossover memory
- Earn rivals only from rematches, kin, or war/plot opposition (not single kills)
- Add harness coverage for gap, cast, rival, cross-MC, and ambient cut paths
2026-07-22 05:22:16 -05:00

1971 lines
69 KiB
C#

using System;
using UnityEngine;
namespace IdleSpectator;
public static partial class InterestDirector
{
/// <summary>
/// Keep camera on the highest-scored combat participant and rewrite tip/counts
/// from a live <see cref="LiveEnsemble"/> so subject + scale always match focus.
/// Clears fight Labels once combat goes cold.
/// </summary>
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);
}
/// <summary>
/// Refresh active combat scene from the world ensemble. Returns false if no focus.
/// </summary>
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);
// Living rematch counter for rival earning (deduped inside NoteCombatEncounter).
// Kill/death paths must never call this.
if (best != null && foe != null)
{
LifeSagaMemory.NoteCombatEncounter(best, foe, "sticky_combat");
}
return true;
}
/// <summary>
/// Refresh sticky war tip (kingdom populations + follow) while WarFront completion is hot.
/// </summary>
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);
}
// Opposing war camps: notable rivalry when both principals are living.
if (_current.FollowUnit != null
&& _current.RelatedUnit != null
&& !string.IsNullOrEmpty(_current.CorrelationKey))
{
LifeSagaMemory.NoteWarOpposition(
_current.FollowUnit,
_current.RelatedUnit,
_current.CorrelationKey,
"war_front_maintain");
}
else if (_current.FollowUnit != null
&& _current.RelatedUnit != null
&& !string.IsNullOrEmpty(_current.AssetId))
{
LifeSagaMemory.NoteWarOpposition(
_current.FollowUnit,
_current.RelatedUnit,
_current.AssetId + ":" + _current.SubjectId,
"war_front_maintain");
}
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;
}
/// <summary>
/// Refresh sticky plot tip (plotter roster + target kingdom) while PlotActive is hot.
/// </summary>
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);
}
if (_current.FollowUnit != null
&& _current.RelatedUnit != null
&& !string.IsNullOrEmpty(_current.CorrelationKey))
{
LifeSagaMemory.NotePlotOpposition(
_current.FollowUnit,
_current.RelatedUnit,
_current.CorrelationKey,
"plot_cell_maintain");
}
// 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;
}
/// <summary>
/// Refresh sticky family pack tip (roster + alpha promote) while FamilyPack is hot.
/// </summary>
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;
}
/// <summary>
/// Refresh sticky status outbreak tip while StatusOutbreak completion is hot.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Pick a living sticky-roster fighter when the current follow left the scrap.
/// </summary>
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();
}
/// <summary>
/// Allow leaving NamedPair only when a real sided multi owns the scrap
/// (species/kingdom camps with 3+ fighters), never from ambient radius noise.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// When the tip is (or collapses to) a NamedPair Duel, keep the locked living partner
/// instead of adopting the owner's latest attack_target.
/// </summary>
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;
}
/// <summary>
/// Hold Duel ownership whenever Follow+Related still form the live pair.
/// Ambient sticky camps do not unlock a living NamedPair.
/// </summary>
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);
}
/// <summary>
/// True when <paramref name="best"/>/<paramref name="foe"/> are the same two actors as the
/// current pair (possibly swapped). Used to lock Duel tip ownership.
/// </summary>
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());
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Harness: force one combat focus maintenance pass.</summary>
public static void HarnessMaintainCombatFocus()
{
MaintainCombatFocus(Time.unscaledTime, force: true);
}
/// <summary>Harness: force one war-front maintenance pass.</summary>
public static void HarnessMaintainWarFront()
{
MaintainWarFront(Time.unscaledTime, force: true);
}
/// <summary>Harness: force one plot-cell maintenance pass.</summary>
public static void HarnessMaintainPlotCell()
{
MaintainPlotCell(Time.unscaledTime, force: true);
}
/// <summary>Harness: force one family-pack maintenance pass.</summary>
public static void HarnessMaintainFamilyPack()
{
MaintainFamilyPack(Time.unscaledTime, force: true);
}
/// <summary>Harness: force one status-outbreak maintenance pass.</summary>
public static void HarnessMaintainStatusOutbreak()
{
MaintainStatusOutbreak(Time.unscaledTime, force: true);
}
}