- Implement checks for civilized terminology in camera focus events - Update event handling to suppress inappropriate pack language for civilized characters - Enhance narrative presentation to differentiate between family and pack language - Introduce new scenarios to validate significance of beats and relationships - Improve documentation to clarify narrative structure and event significance
2034 lines
72 KiB
C#
2034 lines
72 KiB
C#
using System;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
public static partial class InterestDirector
|
|
{
|
|
private const float CombatHeadlineColdGraceSeconds = 4.5f;
|
|
|
|
/// <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)
|
|
{
|
|
if (_current.Sticky.CombatColdSince < 0f)
|
|
{
|
|
_current.Sticky.CombatColdSince = now;
|
|
return;
|
|
}
|
|
if (now - _current.Sticky.CombatColdSince < CombatHeadlineColdGraceSeconds)
|
|
{
|
|
return;
|
|
}
|
|
ClearStaleCombatLabel(now);
|
|
return;
|
|
}
|
|
_current.Sticky.CombatColdSince = -999f;
|
|
|
|
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 staleNamedPair = !string.IsNullOrEmpty(previousLabel)
|
|
&& previousLabel.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
|
&& (curFollow == null
|
|
|| !curFollow.isAlive()
|
|
|| curRelated == null
|
|
|| !curRelated.isAlive());
|
|
bool promoteCollective = _current.Sticky.HasOpposingSides
|
|
&& _current.Sticky.TotalCount >= 3
|
|
&& (previousLabel.StartsWith(
|
|
"Duel", StringComparison.OrdinalIgnoreCase)
|
|
|| previousLabel.IndexOf(
|
|
" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
&& ((label ?? "").StartsWith(
|
|
"Skirmish", StringComparison.OrdinalIgnoreCase)
|
|
|| (label ?? "").StartsWith(
|
|
"Battle", StringComparison.OrdinalIgnoreCase)
|
|
|| (label ?? "").StartsWith(
|
|
"Mass", StringComparison.OrdinalIgnoreCase));
|
|
string proposedLabel = label ?? "";
|
|
bool allowImmediateReason = staleNamedPair || promoteCollective;
|
|
label = StickyScoreboard.StabilizeCombatReason(
|
|
_current.Sticky,
|
|
previousLabel,
|
|
label,
|
|
Time.unscaledTime,
|
|
allowImmediateReason);
|
|
|
|
// A held named-duel reason and a newly proposed focus are one editorial transition.
|
|
// Keep both old principals until the reason commits, then move identity + reason together.
|
|
if (StickyScoreboard.HoldsPresentedDuelPrincipals(
|
|
previousLabel, proposedLabel, label, allowImmediateReason)
|
|
&& curFollow != null
|
|
&& curFollow.isAlive()
|
|
&& curRelated != null
|
|
&& curRelated.isAlive())
|
|
{
|
|
best = curFollow;
|
|
foe = curRelated;
|
|
}
|
|
|
|
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());
|
|
}
|
|
else if (labelChanged)
|
|
{
|
|
// Keep the visible reason and Watching telemetry coherent without a camera cut
|
|
// or full dossier rebuild for same-focus Duel/Battle/Mass reframes.
|
|
CameraDirector.RefreshWatchPresentation(_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");
|
|
}
|
|
|
|
LifeSagaRoster.NoteFeaturedCrossMcPrincipals(_current);
|
|
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");
|
|
}
|
|
|
|
LifeSagaRoster.NoteFeaturedCrossMcPrincipals(_current);
|
|
|
|
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");
|
|
}
|
|
|
|
LifeSagaRoster.NoteFeaturedCrossMcPrincipals(_current);
|
|
|
|
// 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; solo carriers remain individual status beats.
|
|
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.ClearWatchReason();
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|