- Open war/disaster/outbreak crisis overlays with epilogue_crisis closers - Keep Duel pairs hot only on owned cast; park sleep/freeze; drop ambient scrap pins - Require a fallen theater partner for survivor aftermath; let king_killed cut mid-fight - Gate crisis, combat cold, king cut-in, and living-partner aftermath in harness
5235 lines
178 KiB
C#
5235 lines
178 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using NeoModLoader.services;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Event-centered scene director: registry intake, protected sessions, score-margin
|
|
/// preemption with typed combat/vignette rules, soft 70/30 variety, completion via
|
|
/// <see cref="InterestCompletion.IsActive"/>.
|
|
/// </summary>
|
|
public static class InterestDirector
|
|
{
|
|
public const float MinDwellSeconds = 15f;
|
|
public const float CuriosityDwellSeconds = 6f;
|
|
public const float CuriosityRotateSeconds = 10f;
|
|
public const float SwitchCooldownSeconds = 5f;
|
|
public const float HighTierInterruptAfterSeconds = 3f;
|
|
public const float QuietWorldAmbientAfterSeconds = 8f;
|
|
public const float AmbientRotateSeconds = 20f;
|
|
public const float ActionPollSeconds = 0.75f;
|
|
public const float InputExitGraceSeconds = 2.5f;
|
|
public const float QuietGraceSeconds = 6f;
|
|
public const float CameraSettleGraceSeconds = 3f;
|
|
|
|
private static float _minDwell = MinDwellSeconds;
|
|
private static float _curiosityDwell = CuriosityDwellSeconds;
|
|
private static float _curiosityRotate = CuriosityRotateSeconds;
|
|
private static float _switchCooldown = SwitchCooldownSeconds;
|
|
private static float _settleGrace = CameraSettleGraceSeconds;
|
|
private static float _quietAmbientAfter = QuietWorldAmbientAfterSeconds;
|
|
private static float _ambientRotate = AmbientRotateSeconds;
|
|
private static float _feedsTick = ActionPollSeconds;
|
|
private static float _inputExitGrace = InputExitGraceSeconds;
|
|
private static float _quietGrace = QuietGraceSeconds;
|
|
|
|
private static InterestCandidate _current;
|
|
private static InterestCandidate _interrupted;
|
|
private static float _currentStartedAt = -999f;
|
|
private static float _lastSwitchAt = -999f;
|
|
private static float _lastInterestingAt = -999f;
|
|
private static float _lastAmbientAt = -999f;
|
|
private static float _lastFeedsAt = -999f;
|
|
private static float _enabledAt = -999f;
|
|
private static float _inactiveSince = -999f;
|
|
private static float _lastCombatFocusAt = -999f;
|
|
/// <summary>One Story/lover/discovery cut per sticky combat/war hold via the variety valve.</summary>
|
|
private static bool _varietyValveUsed;
|
|
/// <summary>
|
|
/// Harness-only: treat the current CombatActive scene as cold so peers can cut after a fight
|
|
/// without waiting for WorldBox to drop attack_target / combat tasks.
|
|
/// </summary>
|
|
private static bool _harnessCombatForcedCold;
|
|
private const float CombatFocusThrottleSeconds = 1.25f;
|
|
/// <summary>Large Mass sticky scenes can maintain less often - roster work is heavier.</summary>
|
|
private const float CombatFocusThrottleLargeSeconds = 2.0f;
|
|
private const int CombatFocusLargeParticipantThreshold = 16;
|
|
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
|
|
private const float CombatFocusSwitchMargin = 12f;
|
|
/// <summary>
|
|
/// Collective Mass/Battle: hold theater lead through brief attack_target gaps
|
|
/// so maintain ticks cannot hop across equally-scored mob fighters.
|
|
/// </summary>
|
|
private const float CombatTheaterLeadGraceSeconds = 4f;
|
|
/// <summary>Only a clearly hotter fighter may steal theater lead mid-hold.</summary>
|
|
private const float CombatTheaterLeadSwitchMargin = 40f;
|
|
/// <summary>Same sticky side: never hop across a mob for a near-tie.</summary>
|
|
private const float CombatTheaterLeadSameSideSwitchMargin = 90f;
|
|
/// <summary>Boost the thinner sticky side's best (1-vs-mob spectacles).</summary>
|
|
private const float CombatTheaterLeadOutnumberedBonus = 28f;
|
|
private const float CombatTheaterLeadNearRadius = 20f;
|
|
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
|
|
|
|
public static string CurrentTierName => CurrentScoreLabel;
|
|
|
|
public static string CurrentScoreLabel =>
|
|
_current == null ? "none" : _current.TotalScore.ToString("0.#");
|
|
|
|
public static float CurrentScore =>
|
|
_current == null ? 0f : _current.TotalScore;
|
|
|
|
public static string CurrentLabel =>
|
|
_current == null ? "" : (_current.Label ?? "");
|
|
|
|
public static string CurrentKey =>
|
|
_current == null ? "" : (_current.Key ?? "");
|
|
|
|
public static bool CurrentIsActive
|
|
{
|
|
get
|
|
{
|
|
if (_current == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return InterestCompletion.IsActive(_current, _currentStartedAt, Time.unscaledTime);
|
|
}
|
|
}
|
|
|
|
public static InterestCandidate CurrentCandidate => _current;
|
|
|
|
/// <summary>
|
|
/// True while the current scene is past completion but still held through quiet_grace
|
|
/// (camera may remain; orange reason must be empty).
|
|
/// </summary>
|
|
public static bool InQuietGrace
|
|
{
|
|
get
|
|
{
|
|
if (_current == null || CurrentIsActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return _inactiveSince >= 0f;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Candidate that may drive the orange dossier reason: owns <paramref name="unit"/>
|
|
/// while the director still holds that scene (not quiet_grace).
|
|
/// Unit must be a tip principal (not a sticky bystander under a named duel).
|
|
/// </summary>
|
|
public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit)
|
|
{
|
|
if (unit == null || _current == null || InQuietGrace)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Dossier subject asking for the reason must match camera focus when focused.
|
|
if (MoveCamera.hasFocusUnit()
|
|
&& MoveCamera._focus_unit != null
|
|
&& MoveCamera._focus_unit != unit
|
|
&& EventFeedUtil.SafeId(MoveCamera._focus_unit) != EventFeedUtil.SafeId(unit))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return UnitIsReasonPrincipal(unit, _current) ? _current : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="unit"/> may show <paramref name="scene"/>'s Label as orange reason.
|
|
/// Pair-locked duel tips only name PairOwner/PairPartner - sticky Follow alone is not enough
|
|
/// (avoids bystander dossier + "Duel - A vs B" for unrelated fighters).
|
|
/// </summary>
|
|
public static bool UnitIsReasonPrincipal(Actor unit, InterestCandidate scene)
|
|
{
|
|
if (unit == null || scene == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
long unitId = EventFeedUtil.SafeId(unit);
|
|
if (unitId == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool ensemble = scene.Completion == InterestCompletionKind.CombatActive
|
|
|| scene.Completion == InterestCompletionKind.WarFront
|
|
|| scene.Completion == InterestCompletionKind.PlotActive
|
|
|| scene.Completion == InterestCompletionKind.FamilyPack
|
|
|| scene.Completion == InterestCompletionKind.StatusOutbreak
|
|
|| InterestScoring.IsCombatAction(scene);
|
|
|
|
if (ensemble)
|
|
{
|
|
bool massTheater = scene.Sticky != null
|
|
&& scene.Sticky.HasPresentedScale
|
|
&& scene.Sticky.PresentedScale >= EnsembleScale.Skirmish;
|
|
|
|
if (scene.HasCombatPairLock && !massTheater)
|
|
{
|
|
// Named-pair duel: only the locked duelists own the reason.
|
|
return unitId == scene.PairOwnerId || unitId == scene.PairPartnerId;
|
|
}
|
|
|
|
if (unitId == scene.SubjectId
|
|
|| scene.FollowUnit == unit
|
|
|| unitId == scene.PairOwnerId
|
|
|| unitId == scene.PairPartnerId
|
|
|| unitId == scene.TheaterLeadId)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (StickyRosterContains(scene, unitId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Related alone is not enough for ensemble theaters (crowd / wrong camp).
|
|
return false;
|
|
}
|
|
|
|
// Life / discrete tips: Subject + Related are authoritative.
|
|
// Follow alone is not enough after a camera handoff left SubjectId on someone else
|
|
// (avoids Hillih dossier + "Iksssssa shares intimacy…" stale Label).
|
|
if (scene.SubjectId != 0 && unitId == scene.SubjectId)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (scene.RelatedId != 0 && unitId == scene.RelatedId)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (scene.RelatedUnit == unit)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (scene.FollowUnit == unit
|
|
&& (scene.SubjectId == 0 || unitId == scene.SubjectId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool StickyRosterContains(InterestCandidate scene, long unitId)
|
|
{
|
|
if (scene?.Sticky == null || unitId == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
LiveSceneStickyState sticky = scene.Sticky;
|
|
for (int i = 0; i < sticky.SideAIds.Count; i++)
|
|
{
|
|
if (sticky.SideAIds[i] == unitId)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < sticky.SideBIds.Count; i++)
|
|
{
|
|
if (sticky.SideBIds[i] == unitId)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public static string InterruptedKey =>
|
|
_interrupted == null ? "" : (_interrupted.Key ?? "");
|
|
|
|
public static InterestLeadKind? CurrentLeadKind =>
|
|
_current == null ? (InterestLeadKind?)null : _current.LeadKind;
|
|
|
|
public static bool InInputGrace =>
|
|
SpectatorMode.Active && Time.unscaledTime - _enabledAt < _inputExitGrace;
|
|
|
|
public static void SetHarnessFastTiming(bool enabled)
|
|
{
|
|
if (enabled)
|
|
{
|
|
_minDwell = 1.5f;
|
|
_curiosityDwell = 1.2f;
|
|
_curiosityRotate = 1.5f;
|
|
_switchCooldown = 0.75f;
|
|
_settleGrace = 0.75f;
|
|
_quietAmbientAfter = 1.5f;
|
|
_ambientRotate = 2.5f;
|
|
_feedsTick = 0.35f;
|
|
_inputExitGrace = 0.6f;
|
|
_quietGrace = 0.35f;
|
|
}
|
|
else
|
|
{
|
|
_minDwell = MinDwellSeconds;
|
|
_curiosityDwell = CuriosityDwellSeconds;
|
|
_curiosityRotate = CuriosityRotateSeconds;
|
|
_switchCooldown = SwitchCooldownSeconds;
|
|
_settleGrace = CameraSettleGraceSeconds;
|
|
_quietAmbientAfter = QuietWorldAmbientAfterSeconds;
|
|
_ambientRotate = AmbientRotateSeconds;
|
|
_feedsTick = ActionPollSeconds;
|
|
_inputExitGrace = InputExitGraceSeconds;
|
|
_quietGrace = QuietGraceSeconds;
|
|
}
|
|
}
|
|
|
|
public static void HarnessAgeCurrent(float seconds)
|
|
{
|
|
float age = Mathf.Max(0f, seconds);
|
|
float now = Time.unscaledTime;
|
|
_currentStartedAt = now - age;
|
|
_lastSwitchAt = now - age;
|
|
}
|
|
|
|
public static void HarnessExpireInputGrace()
|
|
{
|
|
_enabledAt = Time.unscaledTime - _inputExitGrace - 0.05f;
|
|
}
|
|
|
|
/// <summary>Harness: force the given candidate as the active session.</summary>
|
|
public static bool HarnessForceSession(InterestCandidate candidate)
|
|
{
|
|
if (candidate == null || !candidate.HasValidPosition)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (candidate.Completion == InterestCompletionKind.CombatActive)
|
|
{
|
|
candidate.ClearCombatSticky();
|
|
}
|
|
|
|
EventFeedUtil.RegisterCandidate(candidate);
|
|
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Harness: end the active scene (may resume an Epic-interrupted one).</summary>
|
|
public static void HarnessEndCurrent(string reason = "harness")
|
|
{
|
|
EndCurrent(Time.unscaledTime, reason);
|
|
}
|
|
|
|
/// <summary>Harness: drop ForceActive so FixedDwell / quiet grace can finish the scene.</summary>
|
|
public static void HarnessReleaseForceActive()
|
|
{
|
|
if (_current != null)
|
|
{
|
|
_current.ForceActive = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: mark the current scene inactive for <paramref name="inactiveSeconds"/>
|
|
/// so quiet-grace completion can be asserted.
|
|
/// </summary>
|
|
public static void HarnessMarkInactive(float inactiveSeconds)
|
|
{
|
|
if (_current == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_current.ForceActive = false;
|
|
if (_current.Completion == InterestCompletionKind.Manual)
|
|
{
|
|
_current.Completion = InterestCompletionKind.FixedDwell;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
// Age past FixedDwell / Manual MaxWatch so IsActive is false.
|
|
float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 0.5f;
|
|
_currentStartedAt = now - pastActive;
|
|
_lastSwitchAt = _currentStartedAt;
|
|
_inactiveSince = now - Mathf.Max(0f, inactiveSeconds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: force the current combat scene cold (fight over) without ending the session,
|
|
/// so quiet-grace / variety-valve peers may cut after a live hold.
|
|
/// </summary>
|
|
/// <summary>Harness: current CombatActive is forced cold until the next scene switch.</summary>
|
|
public static bool HarnessCombatForcedCold => _harnessCombatForcedCold;
|
|
|
|
public static void HarnessMarkCombatCold()
|
|
{
|
|
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
_harnessCombatForcedCold = true;
|
|
_current.ForceActive = false;
|
|
_current.LastSeenAt = now - 60f;
|
|
// Skip HasLiveCombatNear sticky path while proving post-fight cuts.
|
|
if (string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_current.AssetId = "live_combat";
|
|
}
|
|
|
|
_inactiveSince = now;
|
|
StoryPlanner.OnClimaxCold(_current);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: force WarFront / PlotActive / CombatActive cold so story aftermath can inject.
|
|
/// </summary>
|
|
public static void HarnessMarkStickySceneCold()
|
|
{
|
|
if (_current == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
if (_current.Completion == InterestCompletionKind.CombatActive)
|
|
{
|
|
HarnessMarkCombatCold();
|
|
return;
|
|
}
|
|
|
|
_current.ForceActive = false;
|
|
_current.LastSeenAt = now - 60f;
|
|
// Break sticky liveness predicates (sides / plotters).
|
|
_current.Sticky.Clear();
|
|
_current.ParticipantCount = 0;
|
|
// Age past MaxWatch so IsActive is false even if a path still resolves.
|
|
float past = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 1f;
|
|
_currentStartedAt = now - past;
|
|
_lastSwitchAt = _currentStartedAt;
|
|
_inactiveSince = now;
|
|
StoryPlanner.OnClimaxCold(_current);
|
|
}
|
|
|
|
/// <summary>Harness: clear FollowUnit while keeping RelatedUnit for death handoff.</summary>
|
|
public static bool HarnessClearFollowForHandoff(Actor related)
|
|
{
|
|
if (_current == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (related != null && related.isAlive())
|
|
{
|
|
_current.RelatedUnit = related;
|
|
try
|
|
{
|
|
_current.RelatedId = related.getID();
|
|
}
|
|
catch
|
|
{
|
|
_current.RelatedId = 0;
|
|
}
|
|
}
|
|
|
|
if (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Transfer durable pair ownership to the handoff unit so maintain does not restore the old Follow.
|
|
long newOwner = EventFeedUtil.SafeId(_current.RelatedUnit);
|
|
long oldOwner = _current.PairOwnerId;
|
|
_current.PairOwnerId = newOwner;
|
|
_current.PairPartnerId = oldOwner != 0 && oldOwner != newOwner ? oldOwner : 0;
|
|
_current.FollowUnit = null;
|
|
_current.SubjectId = 0;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Harness: run one SelectNext without switching (lead-kind probe).</summary>
|
|
public static InterestCandidate HarnessPeekNext()
|
|
{
|
|
return SelectNext(Time.unscaledTime);
|
|
}
|
|
|
|
public static void OnSpectatorEnabled()
|
|
{
|
|
InterestRegistry.Clear();
|
|
InterestVariety.Clear();
|
|
InterestScoring.ClearCache();
|
|
InterestFeeds.Reset();
|
|
StoryPlanner.Clear();
|
|
_current = null;
|
|
_interrupted = null;
|
|
float now = Time.unscaledTime;
|
|
_currentStartedAt = now;
|
|
_lastSwitchAt = now;
|
|
_lastInterestingAt = now;
|
|
_lastAmbientAt = -999f;
|
|
_lastFeedsAt = -999f;
|
|
_enabledAt = now;
|
|
_inactiveSince = -999f;
|
|
// Harness scenarios inject their own candidates after enable/focus.
|
|
if (!AgentHarness.Busy)
|
|
{
|
|
TryCharacterFill(force: true);
|
|
}
|
|
}
|
|
|
|
public static void OnSpectatorDisabled()
|
|
{
|
|
_current = null;
|
|
_interrupted = null;
|
|
InterestRegistry.Clear();
|
|
// Drop harness/live crisis overlays so a war_front_sticky leftover cannot
|
|
// fire epilogue_crisis into the next idle soak.
|
|
StoryPlanner.Clear();
|
|
}
|
|
|
|
public static void PauseForBrowsing(string banner = "Paused (viewing history)")
|
|
{
|
|
if (SpectatorMode.Active)
|
|
{
|
|
SpectatorMode.SetActive(false, quiet: true);
|
|
}
|
|
|
|
WatchCaption.PinWhilePaused();
|
|
if (!string.IsNullOrEmpty(banner))
|
|
{
|
|
WatchCaption.ShowStatusBanner(banner);
|
|
}
|
|
|
|
LogService.LogInfo("[IdleSpectator] Spectator paused (viewing history)");
|
|
}
|
|
|
|
public static void Update()
|
|
{
|
|
if (!SpectatorMode.Active || !Config.game_loaded || World.world == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
WatchCaption.ClearPausePin();
|
|
|
|
if (AgentHarness.FreezeDirector)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Time.unscaledTime - _enabledAt >= _inputExitGrace
|
|
&& PlayerTookManualControl())
|
|
{
|
|
ExitFromManualInput();
|
|
return;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
if (now - _lastFeedsAt >= _feedsTick)
|
|
{
|
|
_lastFeedsAt = now;
|
|
InterestFeeds.Tick();
|
|
InterestRegistry.ExpireStale(now);
|
|
}
|
|
|
|
float onCurrent = now - _currentStartedAt;
|
|
float sinceSwitch = now - _lastSwitchAt;
|
|
|
|
StoryPlanner.Tick(now);
|
|
UpdateSessionLiveness(now, onCurrent);
|
|
MaintainCombatFocus(now, force: false);
|
|
MaintainWarFront(now, force: false);
|
|
MaintainPlotCell(now, force: false);
|
|
MaintainFamilyPack(now, force: false);
|
|
MaintainStatusOutbreak(now, force: false);
|
|
|
|
InterestCandidate next = SelectNext(now);
|
|
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
|
|
{
|
|
if (!InterestScoring.IsFillScore(next.TotalScore))
|
|
{
|
|
_lastInterestingAt = now;
|
|
}
|
|
|
|
SwitchTo(next, now, resumableInterrupt: false);
|
|
onCurrent = 0f;
|
|
sinceSwitch = 0f;
|
|
}
|
|
|
|
// Pending events own the camera - never Character-fill over them.
|
|
if (InterestRegistry.HasPendingEvent())
|
|
{
|
|
// Still repair a missing follow so death mid-scene does not flash the power bar.
|
|
MaintainCameraFocus(now);
|
|
return;
|
|
}
|
|
|
|
bool quiet = now - _lastInterestingAt >= _quietAmbientAfter;
|
|
bool sceneDone = _current == null || !SessionProtected(now, onCurrent);
|
|
bool ambientDue = now - _lastAmbientAt >= _ambientRotate;
|
|
if (_current == null && sinceSwitch >= 0.5f)
|
|
{
|
|
TryCharacterFill(force: true);
|
|
}
|
|
else if (sceneDone && quiet && ambientDue && sinceSwitch >= _switchCooldown)
|
|
{
|
|
TryCharacterFill(force: false);
|
|
}
|
|
|
|
MaintainCameraFocus(now);
|
|
}
|
|
|
|
private static void UpdateSessionLiveness(float now, float onCurrent)
|
|
{
|
|
if (_current == null)
|
|
{
|
|
_inactiveSince = -999f;
|
|
return;
|
|
}
|
|
|
|
// Max cap ends the scene even if still "active" - except live combat, which
|
|
// holds until the scrap goes cold so the viewer can see who wins.
|
|
if (onCurrent >= MaxWatchFor(_current) && !CombatFightIsHot(_current, now))
|
|
{
|
|
EndCurrent(now, reason: "max_cap");
|
|
return;
|
|
}
|
|
|
|
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
|
|
if (active)
|
|
{
|
|
_inactiveSince = -999f;
|
|
TryHandoffFollow();
|
|
return;
|
|
}
|
|
|
|
// Climax went cold: inject / claim aftermath (idempotent per climax key).
|
|
StoryPlanner.OnClimaxCold(_current);
|
|
|
|
// No living subject left: prefer sticky combat roster handoff before ending.
|
|
if (!_current.HasFollowUnit
|
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
|
{
|
|
if (_current.Completion == InterestCompletionKind.CombatActive
|
|
&& StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainCombatFocus(now, force: true);
|
|
return;
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.WarFront
|
|
&& StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainWarFront(now, force: true);
|
|
return;
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.PlotActive
|
|
&& StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainPlotCell(now, force: true);
|
|
return;
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.FamilyPack
|
|
&& StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainFamilyPack(now, force: true);
|
|
return;
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.StatusOutbreak
|
|
&& StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainStatusOutbreak(now, force: true);
|
|
return;
|
|
}
|
|
|
|
EndCurrent(now, reason: "follow_lost");
|
|
return;
|
|
}
|
|
|
|
// Ambient: end promptly when the vignette goes cold - do not hold for minCameraDwell.
|
|
if (IsAmbientShot(_current))
|
|
{
|
|
if (_inactiveSince < 0f)
|
|
{
|
|
_inactiveSince = now;
|
|
}
|
|
|
|
if (now - _inactiveSince >= _quietGrace)
|
|
{
|
|
EndCurrent(now, reason: "quiet_grace");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Condition ended early: still hold the camera until min dwell, then grace.
|
|
if (onCurrent < MinDwellFor(_current))
|
|
{
|
|
_inactiveSince = -999f;
|
|
return;
|
|
}
|
|
|
|
if (_inactiveSince < 0f)
|
|
{
|
|
_inactiveSince = now;
|
|
}
|
|
|
|
if (now - _inactiveSince >= _quietGrace)
|
|
{
|
|
EndCurrent(now, reason: "quiet_grace");
|
|
}
|
|
}
|
|
|
|
private static void TryHandoffFollow()
|
|
{
|
|
if (_current == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.CombatActive)
|
|
{
|
|
MaintainCombatFocus(Time.unscaledTime, force: true);
|
|
if (!_current.HasFollowUnit
|
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
|
{
|
|
if (StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainCombatFocus(Time.unscaledTime, force: true);
|
|
return;
|
|
}
|
|
|
|
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.WarFront)
|
|
{
|
|
MaintainWarFront(Time.unscaledTime, force: true);
|
|
if (!_current.HasFollowUnit
|
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
|
{
|
|
if (StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainWarFront(Time.unscaledTime, force: true);
|
|
return;
|
|
}
|
|
|
|
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.PlotActive)
|
|
{
|
|
MaintainPlotCell(Time.unscaledTime, force: true);
|
|
if (!_current.HasFollowUnit
|
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
|
{
|
|
if (StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainPlotCell(Time.unscaledTime, force: true);
|
|
return;
|
|
}
|
|
|
|
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.FamilyPack)
|
|
{
|
|
MaintainFamilyPack(Time.unscaledTime, force: true);
|
|
if (!_current.HasFollowUnit
|
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
|
{
|
|
if (StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainFamilyPack(Time.unscaledTime, force: true);
|
|
return;
|
|
}
|
|
|
|
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.StatusOutbreak)
|
|
{
|
|
MaintainStatusOutbreak(Time.unscaledTime, force: true);
|
|
if (!_current.HasFollowUnit
|
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
|
{
|
|
if (StickyScoreboard.TryPromoteFollow(_current))
|
|
{
|
|
MaintainStatusOutbreak(Time.unscaledTime, force: true);
|
|
return;
|
|
}
|
|
|
|
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_current.HasFollowUnit)
|
|
{
|
|
// Living subject still owned - reattach if vanilla cleared focus mid-scene.
|
|
if (!HasLivingCameraFocus() || MoveCamera._focus_unit != _current.FollowUnit)
|
|
{
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Story aftermath/epilogue: promote another living cast member before ending.
|
|
if (StoryPlanner.TryRecoverStoryFollow(_current))
|
|
{
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
return;
|
|
}
|
|
|
|
// Ownership-preserving handoff only: related survivor. Never invent a stranger.
|
|
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
|
|
{
|
|
_current.FollowUnit = _current.RelatedUnit;
|
|
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
return;
|
|
}
|
|
|
|
// No owned related: end the scene rather than attach a nearby stranger.
|
|
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
|
}
|
|
|
|
/// <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);
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
private static bool HasStickyCombatSides(InterestCandidate scene)
|
|
{
|
|
return StickyScoreboard.HasOpposingSides(scene);
|
|
}
|
|
|
|
private static void ResolveCombatPairActors(
|
|
InterestCandidate scene,
|
|
out Actor owner,
|
|
out Actor partner)
|
|
{
|
|
owner = null;
|
|
partner = null;
|
|
if (scene == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (scene.PairOwnerId != 0)
|
|
{
|
|
owner = LiveEnsemble.FindTrackedActor(scene.PairOwnerId);
|
|
}
|
|
|
|
if (owner == null || !owner.isAlive())
|
|
{
|
|
owner = scene.FollowUnit != null && scene.FollowUnit.isAlive()
|
|
? scene.FollowUnit
|
|
: null;
|
|
}
|
|
|
|
if (scene.PairPartnerId != 0)
|
|
{
|
|
partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId);
|
|
}
|
|
|
|
if (partner == null || !partner.isAlive())
|
|
{
|
|
partner = scene.RelatedUnit != null && scene.RelatedUnit.isAlive()
|
|
? scene.RelatedUnit
|
|
: null;
|
|
}
|
|
|
|
if (partner != null && owner != null && partner == owner)
|
|
{
|
|
partner = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the scene already owns a collective multi battle (post-escalate),
|
|
/// not a NamedPair duel that ambient units merely wandered near.
|
|
/// </summary>
|
|
private static bool HasStickyCollectiveMulti(InterestCandidate scene)
|
|
{
|
|
if (scene == null || !HasStickyCombatSides(scene))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (scene.CombatSideFrame != EnsembleFrame.SpeciesVsSpecies
|
|
&& scene.CombatSideFrame != EnsembleFrame.KingdomVsKingdom)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// AssetId==live_battle alone is not enough - ambient probes set it without a real multi.
|
|
int enrolled = scene.CombatSideACount + scene.CombatSideBCount;
|
|
return scene.CombatPeakParticipants >= 3 || enrolled >= 3;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Living 1v1 ownership: Duel tips and forced pair sessions stay NamedPair until a
|
|
/// real multi escalate (sticky collective camps), not ambient nearby melees.
|
|
/// </summary>
|
|
private static bool IsNamedPairCombatOwnership(InterestCandidate scene)
|
|
{
|
|
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ResolveCombatPairActors(scene, out Actor owner, out Actor partner);
|
|
bool pairIdsLive = owner != null && owner.isAlive()
|
|
&& partner != null && partner.isAlive();
|
|
|
|
if (HasStickyCollectiveMulti(scene))
|
|
{
|
|
string label = scene.Label ?? "";
|
|
// Explicit Duel tip wins over ambient sticky pollution; collective tips own the scene.
|
|
return label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
if (scene.HasCombatPairLock && pairIdsLive)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string tip = scene.Label ?? "";
|
|
bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
|
|
bool thinFight = tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
bool hasPartner = partner != null || (scene.RelatedUnit != null && scene.RelatedUnit.isAlive());
|
|
if (duelLabeled)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (scene.ForceActive && hasPartner)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (hasPartner
|
|
&& owner != null
|
|
&& owner.isAlive()
|
|
&& (thinFight || scene.ParticipantCount <= 2 || scene.CombatPeakParticipants <= 2))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Collective sticky Mass/Battle/Skirmish (not NamedPair duel ownership).
|
|
/// </summary>
|
|
private static bool IsCollectiveCombatTheater(InterestCandidate scene)
|
|
{
|
|
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (IsNamedPairCombatOwnership(scene))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!HasStickyCombatSides(scene))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return HasStickyCollectiveMulti(scene)
|
|
|| scene.CombatPeakParticipants >= 3
|
|
|| scene.ParticipantCount >= 3;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Score a sticky-side champion for theater-lead selection (stable, no newcomer jitter).
|
|
/// </summary>
|
|
private static float TheaterLeadScore(InterestCandidate scene, Actor actor, int ownSideCount, int foeSideCount)
|
|
{
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
return -1f;
|
|
}
|
|
|
|
float weight = LiveEnsemble.FocusWeight(actor, scene?.ParticipantIds, newcomerBonus: 0f);
|
|
if (weight < 0f)
|
|
{
|
|
return weight;
|
|
}
|
|
|
|
bool outnumbered = ownSideCount > 0
|
|
&& foeSideCount > 0
|
|
&& ownSideCount < foeSideCount
|
|
&& foeSideCount >= 3;
|
|
if (!outnumbered)
|
|
{
|
|
return weight;
|
|
}
|
|
|
|
// 1-vs-mob: prefer the thinner side's champion (evil mage vs humans).
|
|
weight += CombatTheaterLeadOutnumberedBonus;
|
|
|
|
// Spectacle on the thin side must beat crowned kings / renown stacks on the mob
|
|
// (soak: 1 evil mage vs 100+ elves still followed a random elf).
|
|
if (WorldActivityScanner.IsSpectaclePublic(actor))
|
|
{
|
|
float ratio = foeSideCount / (float)Mathf.Max(1, ownSideCount);
|
|
weight += Mathf.Clamp(40f + ratio * 6f, 60f, 140f);
|
|
}
|
|
|
|
return weight;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pick the highest-scoring living fighter across both sticky sides.
|
|
/// </summary>
|
|
private static bool TryPickTheaterLead(
|
|
InterestCandidate scene,
|
|
out Actor lead,
|
|
out Actor foe)
|
|
{
|
|
lead = 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);
|
|
if ((pickA == null || !pickA.isAlive()) && (pickB == null || !pickB.isAlive()))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int countA = Mathf.Max(1, sticky.SideACount);
|
|
int countB = Mathf.Max(1, sticky.SideBCount);
|
|
float wA = TheaterLeadScore(scene, pickA, countA, countB);
|
|
float wB = TheaterLeadScore(scene, pickB, countB, countA);
|
|
if (wB > wA)
|
|
{
|
|
lead = pickB;
|
|
foe = pickA;
|
|
}
|
|
else
|
|
{
|
|
lead = pickA;
|
|
foe = pickB;
|
|
}
|
|
|
|
return lead != null && lead.isAlive();
|
|
}
|
|
|
|
/// <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 Actor ResolveAttackFoe(Actor actor)
|
|
{
|
|
if (actor == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (actor.has_attack_target
|
|
&& actor.attack_target != null
|
|
&& actor.attack_target.isAlive()
|
|
&& actor.attack_target.isActor())
|
|
{
|
|
Actor foe = actor.attack_target.a;
|
|
return foe != null && foe.isAlive() ? foe : null;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rewatch tip only when tier or collective sides change - not on (6)↔(5) count flaps.
|
|
/// </summary>
|
|
private static bool CombatLabelNeedsRewatch(string oldLabel, string newLabel)
|
|
{
|
|
if (string.IsNullOrEmpty(oldLabel))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(newLabel))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string a = StripCombatLabelCounts(oldLabel);
|
|
string b = StripCombatLabelCounts(newLabel);
|
|
return !a.Equals(b, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string StripCombatLabelCounts(string label)
|
|
{
|
|
if (string.IsNullOrEmpty(label))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var sb = new System.Text.StringBuilder(label.Length);
|
|
for (int i = 0; i < label.Length; i++)
|
|
{
|
|
char c = label[i];
|
|
if (c == '(')
|
|
{
|
|
int close = label.IndexOf(')', i);
|
|
if (close > i)
|
|
{
|
|
i = close;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
sb.Append(c);
|
|
}
|
|
|
|
return sb.ToString().Trim();
|
|
}
|
|
|
|
private static void ApplyEnsembleFields(InterestCandidate scene, LiveEnsemble ensemble)
|
|
{
|
|
if (scene == null || ensemble == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
scene.ParticipantCount = Math.Max(0, ensemble.ParticipantCount);
|
|
if (scene.ParticipantCount > scene.CombatPeakParticipants)
|
|
{
|
|
scene.CombatPeakParticipants = scene.ParticipantCount;
|
|
}
|
|
|
|
scene.NotableParticipantCount = ensemble.NotableParticipantCount;
|
|
WorldActivityScanner.StampParticipantIds(scene, ensemble);
|
|
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
|
{
|
|
scene.AssetId = "live_battle";
|
|
}
|
|
|
|
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
|
|
&& ensemble.SideA != null
|
|
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
|
|
{
|
|
scene.KingdomKey = ensemble.SideA.Key;
|
|
}
|
|
|
|
if (ensemble.Frame == EnsembleFrame.SpeciesVsSpecies
|
|
&& ensemble.SideA != null
|
|
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
|
|
{
|
|
scene.SpeciesId = ensemble.SideA.Key;
|
|
}
|
|
|
|
StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble);
|
|
}
|
|
|
|
/// <summary>
|
|
/// If the active combat/war scene already covers this battle anchor, refresh it in place
|
|
/// instead of registering a second live_battle candidate.
|
|
/// </summary>
|
|
public static bool TryAbsorbCombatBattle(InterestEvent battle)
|
|
{
|
|
if (battle == null || _current == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// War chapter owns the kingdom theater - local Mass scraps refresh follow in place.
|
|
if (_current.Completion == InterestCompletionKind.WarFront
|
|
&& BattleSharesCurrentWarTheater(battle))
|
|
{
|
|
if (battle.FollowUnit != null && battle.FollowUnit.isAlive())
|
|
{
|
|
_current.FollowUnit = battle.FollowUnit;
|
|
_current.SubjectId = EventFeedUtil.SafeId(battle.FollowUnit);
|
|
try
|
|
{
|
|
_current.Position = battle.FollowUnit.current_position;
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
}
|
|
|
|
if (battle.ParticipantCount > _current.ParticipantCount)
|
|
{
|
|
_current.ParticipantCount = battle.ParticipantCount;
|
|
}
|
|
|
|
ApplyWarFrontMaintain(forceWatch: true);
|
|
return true;
|
|
}
|
|
|
|
if (_current.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Vector3 anchor = battle.Position;
|
|
Vector3 cur = _current.Position;
|
|
try
|
|
{
|
|
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
|
|
{
|
|
cur = _current.FollowUnit.current_position;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
|
|
float dx = anchor.x - cur.x;
|
|
float dy = anchor.y - cur.y;
|
|
float mergeR = 16f;
|
|
bool near = dx * dx + dy * dy <= mergeR * mergeR;
|
|
bool shared = false;
|
|
if (!near && battle.FollowUnit != null)
|
|
{
|
|
long id = EventFeedUtil.SafeId(battle.FollowUnit);
|
|
shared = id != 0 && _current.ParticipantIds != null && _current.ParticipantIds.Contains(id);
|
|
}
|
|
|
|
if (!near && !shared)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (battle.ParticipantCount > _current.ParticipantCount)
|
|
{
|
|
_current.ParticipantCount = battle.ParticipantCount;
|
|
}
|
|
|
|
if (battle.NotableParticipantCount > _current.NotableParticipantCount)
|
|
{
|
|
_current.NotableParticipantCount = battle.NotableParticipantCount;
|
|
}
|
|
|
|
ApplyCombatEnsembleToCurrent(forceWatch: true);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when a scanner battle belongs to the current WarFront kingdom pair
|
|
/// (kingdom keys on the battle follow / related, or proximity inside war theater).
|
|
/// </summary>
|
|
private static bool BattleSharesCurrentWarTheater(InterestEvent battle)
|
|
{
|
|
if (battle == null
|
|
|| _current == null
|
|
|| _current.Completion != InterestCompletionKind.WarFront
|
|
|| _current.Sticky == null
|
|
|| !StickyScoreboard.TryKingdomPair(_current.Sticky, out string warA, out string warB))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string followK = LiveEnsemble.KingdomKeyOf(battle.FollowUnit);
|
|
string relatedK = LiveEnsemble.KingdomKeyOf(battle.RelatedUnit);
|
|
bool followMatch = KingdomOnWarPair(followK, warA, warB);
|
|
bool relatedMatch = KingdomOnWarPair(relatedK, warA, warB);
|
|
if (followMatch && (relatedMatch || string.IsNullOrEmpty(relatedK)))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (followMatch && relatedMatch)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Near the war camera anchor inside war theater radius.
|
|
Vector3 anchor = battle.Position;
|
|
Vector3 cur = _current.Position;
|
|
try
|
|
{
|
|
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
|
|
{
|
|
cur = _current.FollowUnit.current_position;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
|
|
float dx = anchor.x - cur.x;
|
|
float dy = anchor.y - cur.y;
|
|
float r = StickyScoreboard.WarTheaterRadius;
|
|
return followMatch && dx * dx + dy * dy <= r * r;
|
|
}
|
|
|
|
private static bool KingdomOnWarPair(string kingdom, string warA, string warB)
|
|
{
|
|
if (string.IsNullOrEmpty(kingdom))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string k = kingdom.Trim().ToLowerInvariant();
|
|
return string.Equals(k, warA, StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(k, warB, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>Harness: apply a synthetic combat ensemble onto the current scene.</summary>
|
|
public static bool HarnessApplyCombatEnsemble(LiveEnsemble ensemble)
|
|
{
|
|
if (_current == null
|
|
|| ensemble == null
|
|
|| !ensemble.HasFocus
|
|
|| _current.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int authoredN = Mathf.Max(
|
|
ensemble.ParticipantCount,
|
|
(ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0));
|
|
ApplyEnsembleFields(_current, ensemble);
|
|
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
|
|
StickyScoreboard.Refresh(
|
|
_current,
|
|
ensemble,
|
|
ensemble.Focus.current_position,
|
|
14f);
|
|
// Harness escalate authors side counts before units fully enter combat scans.
|
|
if (authoredN > _current.ParticipantCount)
|
|
{
|
|
_current.ParticipantCount = authoredN;
|
|
}
|
|
|
|
if (ensemble.SideA != null && ensemble.SideA.Count > _current.CombatSideACount)
|
|
{
|
|
_current.CombatSideACount = ensemble.SideA.Count;
|
|
}
|
|
|
|
if (ensemble.SideB != null && ensemble.SideB.Count > _current.CombatSideBCount)
|
|
{
|
|
_current.CombatSideBCount = ensemble.SideB.Count;
|
|
}
|
|
|
|
if (authoredN > _current.CombatPeakParticipants)
|
|
{
|
|
_current.CombatPeakParticipants = authoredN;
|
|
}
|
|
|
|
// Keep PairOwner/Partner + theater lead across escalate. Clearing them forced
|
|
// Mass→Duel collapse onto whichever attack_target the lead had that tick.
|
|
|
|
Actor focus = ensemble.Focus;
|
|
Actor related = ensemble.Related;
|
|
if (IsCollectiveCombatTheater(_current)
|
|
|| (authoredN >= 3 && HasStickyCombatSides(_current)))
|
|
{
|
|
if (TryPickTheaterLead(_current, out Actor lead, out Actor leadFoe))
|
|
{
|
|
focus = lead;
|
|
related = ResolveAttackFoe(lead) ?? leadFoe ?? related;
|
|
_current.StampTheaterLead(lead);
|
|
if (LiveEnsemble.IsCombatParticipant(lead))
|
|
{
|
|
_current.TheaterLeadLastCombatAt = Time.unscaledTime;
|
|
}
|
|
|
|
ensemble.Focus = focus;
|
|
ensemble.Related = related;
|
|
}
|
|
}
|
|
|
|
_current.FollowUnit = focus;
|
|
_current.SubjectId = EventFeedUtil.SafeId(focus);
|
|
_current.RelatedUnit = related;
|
|
_current.RelatedId = related != null ? EventFeedUtil.SafeId(related) : 0;
|
|
_current.Label = EventReason.Combat(ensemble);
|
|
try
|
|
{
|
|
_current.Position = focus.current_position;
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
return true;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>Harness: apply a synthetic war ensemble onto the current WarFront scene.</summary>
|
|
public static bool HarnessApplyWarEnsemble(LiveEnsemble ensemble)
|
|
{
|
|
if (_current == null
|
|
|| ensemble == null
|
|
|| !ensemble.HasFocus
|
|
|| _current.Completion != InterestCompletionKind.WarFront)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ensemble.Kind = EnsembleKind.WarFront;
|
|
ensemble.Frame = EnsembleFrame.KingdomVsKingdom;
|
|
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
|
|
StickyScoreboard.Refresh(
|
|
_current,
|
|
ensemble,
|
|
ensemble.Focus.current_position,
|
|
StickyScoreboard.WarTheaterRadius);
|
|
_current.FollowUnit = ensemble.Focus;
|
|
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
|
_current.RelatedUnit = ensemble.Related;
|
|
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
|
|
_current.Label = EventReason.WarFront(ensemble);
|
|
_current.AssetId = "live_war";
|
|
_current.ParticipantCount = ensemble.ParticipantCount;
|
|
try
|
|
{
|
|
_current.Position = ensemble.Focus.current_position;
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Harness: apply a synthetic family ensemble onto the current FamilyPack scene.</summary>
|
|
public static bool HarnessApplyFamilyEnsemble(LiveEnsemble ensemble)
|
|
{
|
|
if (_current == null
|
|
|| ensemble == null
|
|
|| !ensemble.HasFocus
|
|
|| _current.Completion != InterestCompletionKind.FamilyPack)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ensemble.Kind = EnsembleKind.FamilyPack;
|
|
ensemble.Frame = EnsembleFrame.SpeciesVsSpecies;
|
|
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
|
|
if (ensemble.SideA != null)
|
|
{
|
|
_current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count);
|
|
if (!string.IsNullOrEmpty(ensemble.SideA.Key))
|
|
{
|
|
_current.Sticky.SideAKey = ensemble.SideA.Key;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(ensemble.SideA.Display)
|
|
&& !LiveEnsemble.IsFamilyPackSideKey(ensemble.SideA.Display))
|
|
{
|
|
_current.Sticky.SideADisplay = ensemble.SideA.Display;
|
|
}
|
|
|
|
ensemble.SideA.Count = _current.Sticky.SideACount;
|
|
ensemble.SideA.Display = _current.Sticky.SideADisplay;
|
|
}
|
|
|
|
_current.Sticky.SideBKey = "";
|
|
_current.Sticky.SideBDisplay = "";
|
|
_current.Sticky.SideBCount = 0;
|
|
_current.Sticky.PeakParticipants = Math.Max(
|
|
_current.Sticky.PeakParticipants,
|
|
_current.Sticky.SideACount);
|
|
_current.FollowUnit = ensemble.Focus;
|
|
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
|
_current.RelatedUnit = ensemble.Related;
|
|
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
|
|
if (_current.Sticky != null && ensemble.ParticipantIds != null)
|
|
{
|
|
_current.Sticky.SideAIds.Clear();
|
|
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
|
|
{
|
|
long id = ensemble.ParticipantIds[i];
|
|
if (id != 0)
|
|
{
|
|
_current.Sticky.SideAIds.Add(id);
|
|
}
|
|
}
|
|
|
|
if (ensemble.SideA != null)
|
|
{
|
|
_current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count);
|
|
}
|
|
}
|
|
|
|
_current.Label = EventReason.FamilyPack(ensemble);
|
|
if (string.IsNullOrEmpty(_current.Label))
|
|
{
|
|
_current.Label = StickyScoreboard.FormatReason(_current, ensemble.Focus, ensemble.Related);
|
|
}
|
|
|
|
_current.AssetId = "live_family";
|
|
_current.ParticipantCount = ensemble.ParticipantCount;
|
|
try
|
|
{
|
|
_current.Position = ensemble.Focus.current_position;
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Harness: apply a synthetic outbreak ensemble onto the current StatusOutbreak scene.</summary>
|
|
public static bool HarnessApplyStatusOutbreakEnsemble(LiveEnsemble ensemble)
|
|
{
|
|
if (_current == null
|
|
|| ensemble == null
|
|
|| !ensemble.HasFocus
|
|
|| _current.Completion != InterestCompletionKind.StatusOutbreak)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ensemble.Kind = EnsembleKind.StatusOutbreak;
|
|
ensemble.Frame = EnsembleFrame.SpeciesVsSpecies;
|
|
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
|
|
if (ensemble.SideA != null)
|
|
{
|
|
_current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count);
|
|
if (!string.IsNullOrEmpty(ensemble.SideA.Key))
|
|
{
|
|
_current.Sticky.SideAKey = ensemble.SideA.Key;
|
|
if (LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key))
|
|
{
|
|
_current.StatusId = ensemble.SideA.Key.Substring("status:".Length);
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(ensemble.SideA.Display)
|
|
&& !LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Display))
|
|
{
|
|
_current.Sticky.SideADisplay = ensemble.SideA.Display;
|
|
}
|
|
|
|
ensemble.SideA.Count = _current.Sticky.SideACount;
|
|
ensemble.SideA.Display = _current.Sticky.SideADisplay;
|
|
}
|
|
|
|
_current.Sticky.SideBKey = "";
|
|
_current.Sticky.SideBDisplay = "";
|
|
_current.Sticky.SideBCount = 0;
|
|
_current.Sticky.PeakParticipants = Math.Max(
|
|
_current.Sticky.PeakParticipants,
|
|
_current.Sticky.SideACount);
|
|
_current.FollowUnit = ensemble.Focus;
|
|
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
|
_current.RelatedUnit = ensemble.Related;
|
|
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
|
|
_current.Label = EventReason.StatusOutbreak(ensemble);
|
|
if (string.IsNullOrEmpty(_current.Label))
|
|
{
|
|
_current.Label = StickyScoreboard.FormatReason(_current, ensemble.Focus, ensemble.Related);
|
|
}
|
|
|
|
_current.AssetId = "live_outbreak";
|
|
_current.ParticipantCount = ensemble.ParticipantCount;
|
|
try
|
|
{
|
|
_current.Position = ensemble.Focus.current_position;
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Harness: apply a synthetic plot ensemble onto the current PlotActive scene.</summary>
|
|
public static bool HarnessApplyPlotEnsemble(LiveEnsemble ensemble)
|
|
{
|
|
if (_current == null
|
|
|| ensemble == null
|
|
|| !ensemble.HasFocus
|
|
|| _current.Completion != InterestCompletionKind.PlotActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ensemble.Kind = EnsembleKind.PlotCell;
|
|
ensemble.Frame = EnsembleFrame.KingdomVsKingdom;
|
|
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
|
|
// Harness drives explicit counts; seed roster then pin counts for tip asserts.
|
|
if (ensemble.SideA != null)
|
|
{
|
|
_current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count);
|
|
_current.Sticky.SideADisplay = "Plotters";
|
|
if (!string.IsNullOrEmpty(ensemble.SideA.Key))
|
|
{
|
|
_current.Sticky.SideAKey = ensemble.SideA.Key;
|
|
}
|
|
}
|
|
|
|
if (ensemble.SideB != null)
|
|
{
|
|
_current.Sticky.SideBCount = Math.Max(0, ensemble.SideB.Count);
|
|
if (!string.IsNullOrEmpty(ensemble.SideB.Key))
|
|
{
|
|
_current.Sticky.SideBKey = ensemble.SideB.Key;
|
|
_current.Sticky.SideBDisplay = string.IsNullOrEmpty(ensemble.SideB.Display)
|
|
? LiveEnsemble.KingdomDisplay(ensemble.SideB.Key)
|
|
: ensemble.SideB.Display;
|
|
_current.Sticky.SideBKingdom = _current.Sticky.SideBDisplay;
|
|
}
|
|
}
|
|
|
|
if (ensemble.SideA != null)
|
|
{
|
|
ensemble.SideA.Display = "Plotters";
|
|
ensemble.SideA.Count = _current.Sticky.SideACount;
|
|
}
|
|
|
|
if (ensemble.SideB != null)
|
|
{
|
|
ensemble.SideB.Count = _current.Sticky.SideBCount;
|
|
ensemble.SideB.Display = _current.Sticky.SideBDisplay;
|
|
}
|
|
|
|
_current.Sticky.PeakParticipants = Math.Max(
|
|
_current.Sticky.PeakParticipants,
|
|
_current.Sticky.TotalCount);
|
|
_current.FollowUnit = ensemble.Focus;
|
|
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
|
_current.RelatedUnit = ensemble.Related;
|
|
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
|
|
// Groups only - leave Label empty below 2 plotters (no unit-plot fallback on sticky).
|
|
_current.Label = EventReason.PlotCell(ensemble) ?? "";
|
|
_current.AssetId = "live_plot";
|
|
_current.ParticipantCount = ensemble.ParticipantCount;
|
|
try
|
|
{
|
|
_current.Position = ensemble.Focus.current_position;
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keep a living focus unit whenever Idle Spectator is on.
|
|
/// Covers death mid-combat and quiet_grace windows where vanilla cleared follow.
|
|
/// </summary>
|
|
private static void MaintainCameraFocus(float now)
|
|
{
|
|
if (!SpectatorMode.Active)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_current != null)
|
|
{
|
|
if (_current.Completion == InterestCompletionKind.CombatActive)
|
|
{
|
|
MaintainCombatFocus(now, force: true);
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.WarFront)
|
|
{
|
|
MaintainWarFront(now, force: true);
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.PlotActive)
|
|
{
|
|
MaintainPlotCell(now, force: true);
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.FamilyPack)
|
|
{
|
|
MaintainFamilyPack(now, force: true);
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_current.Completion == InterestCompletionKind.StatusOutbreak)
|
|
{
|
|
MaintainStatusOutbreak(now, force: true);
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_current.HasFollowUnit)
|
|
{
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (StoryPlanner.TryRecoverStoryFollow(_current))
|
|
{
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
|
|
{
|
|
// Non-combat ownership handoff; sticky scenes already tried picker above.
|
|
if (_current.Completion != InterestCompletionKind.CombatActive
|
|
&& _current.Completion != InterestCompletionKind.WarFront
|
|
&& _current.Completion != InterestCompletionKind.PlotActive
|
|
&& _current.Completion != InterestCompletionKind.FamilyPack
|
|
&& _current.Completion != InterestCompletionKind.StatusOutbreak)
|
|
{
|
|
_current.FollowUnit = _current.RelatedUnit;
|
|
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Orphan scene (no living follow/related) - end and refill.
|
|
EndCurrent(now, reason: "follow_lost");
|
|
return;
|
|
}
|
|
|
|
EnsureIdleFocus(now);
|
|
}
|
|
|
|
private static bool HasLivingCameraFocus()
|
|
{
|
|
return MoveCamera.hasFocusUnit()
|
|
&& MoveCamera._focus_unit != null
|
|
&& MoveCamera._focus_unit.isAlive();
|
|
}
|
|
|
|
private static void EndCurrent(float now, string reason)
|
|
{
|
|
InterestCandidate ended = _current;
|
|
if (_current != null)
|
|
{
|
|
InterestRegistry.Remove(_current.Key);
|
|
LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label);
|
|
}
|
|
|
|
StoryPlanner.OnEndCurrent(ended, now);
|
|
|
|
// Resume interrupted Epic-preempted scene if still valid.
|
|
// Soft-life / moment beats already NoteSelection-cooled must not cut back in
|
|
// after a combat/peer cut (soak: hatch/reproduce resume spam).
|
|
if (_interrupted != null
|
|
&& _interrupted.HasValidPosition
|
|
&& InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now))
|
|
{
|
|
InterestCandidate resume = _interrupted;
|
|
_interrupted = null;
|
|
if (InterestVariety.IsBeatCooled(resume, now))
|
|
{
|
|
InterestDropLog.Record(
|
|
"resume_cooled",
|
|
resume.HappinessEffectId ?? resume.AssetId ?? resume.Key ?? "beat");
|
|
InterestRegistry.Remove(resume.Key);
|
|
}
|
|
else
|
|
{
|
|
SwitchTo(resume, now, resumableInterrupt: false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Prefer queued story aftermath/epilogue over ambient fill.
|
|
InterestCandidate storyNext = SelectNext(now);
|
|
if (storyNext != null
|
|
&& StoryPlanner.OwnsCandidate(storyNext)
|
|
&& IsWorthWatchingNow(storyNext, now, selectingDuringGrace: true))
|
|
{
|
|
_interrupted = null;
|
|
_current = null;
|
|
_harnessCombatForcedCold = false;
|
|
_inactiveSince = -999f;
|
|
SwitchTo(storyNext, now, resumableInterrupt: false);
|
|
return;
|
|
}
|
|
|
|
_interrupted = null;
|
|
_current = null;
|
|
_harnessCombatForcedCold = false;
|
|
_inactiveSince = -999f;
|
|
EnsureIdleFocus(now);
|
|
}
|
|
|
|
/// <summary>
|
|
/// After scene end, keep a living focus unit so idle never flashes the power bar.
|
|
/// </summary>
|
|
private static void EnsureIdleFocus(float now)
|
|
{
|
|
if (!SpectatorMode.Active)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
|
|
TryCharacterFill(force: true);
|
|
if (HasLivingCameraFocus())
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor any = EventFeedUtil.AnyAliveUnit();
|
|
if (any != null)
|
|
{
|
|
CameraDirector.FocusUnit(any);
|
|
}
|
|
|
|
_ = now;
|
|
}
|
|
|
|
private static InterestCandidate SelectNext(float now)
|
|
{
|
|
InterestRegistry.CopyPending(PendingScratch);
|
|
if (PendingScratch.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
float onCurrent = now - _currentStartedAt;
|
|
float sinceSwitch = now - _lastSwitchAt;
|
|
for (int i = PendingScratch.Count - 1; i >= 0; i--)
|
|
{
|
|
InterestCandidate c = PendingScratch[i];
|
|
if (c == null || !CanSwitchTo(c, onCurrent, sinceSwitch))
|
|
{
|
|
PendingScratch.RemoveAt(i);
|
|
continue;
|
|
}
|
|
|
|
// Drop stale / not-worth-watching / unpresentable shots from the pending set.
|
|
// Harness synthetic labels may be unpresentable (dossier blanks); keep them for tip tests.
|
|
bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase);
|
|
bool presentable = harness || EventPresentability.WouldShow(c);
|
|
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|
|
|| IsStaleFreshLifeMoment(c, now)
|
|
|| !presentable)
|
|
{
|
|
if (!presentable)
|
|
{
|
|
InterestDropLog.Record("unpresentable", c.Label ?? c.Key);
|
|
}
|
|
|
|
InterestRegistry.Remove(c.Key);
|
|
PendingScratch.RemoveAt(i);
|
|
}
|
|
}
|
|
|
|
if (PendingScratch.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
InterestScoring.EnrichTopK(PendingScratch);
|
|
return InterestVariety.Pick(PendingScratch, _current);
|
|
}
|
|
|
|
private static bool IsStaleFreshLifeMoment(InterestCandidate c, float now)
|
|
{
|
|
if (c == null || string.IsNullOrEmpty(c.HappinessEffectId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string id = c.HappinessEffectId;
|
|
bool hatch = string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase);
|
|
bool spawn = string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase);
|
|
if (!hatch && !spawn)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Queued too long - miss the beat.
|
|
if (now - c.CreatedAt > 5.5f)
|
|
{
|
|
InterestDropLog.Record("stale", c.HappinessEffectId + " queue");
|
|
return true;
|
|
}
|
|
|
|
Actor unit = c.FollowUnit;
|
|
if (unit == null || !unit.isAlive())
|
|
{
|
|
InterestDropLog.Record("stale", c.HappinessEffectId + " dead");
|
|
return true;
|
|
}
|
|
|
|
// Hatch: egg-loss / happiness may land after the unit is already past baby form.
|
|
// Only drop on age for spawn (just_born), not hatch.
|
|
if (spawn)
|
|
{
|
|
try
|
|
{
|
|
if (!unit.isBaby() && unit.getAge() > 1)
|
|
{
|
|
InterestDropLog.Record("stale", "just_born grown");
|
|
return true;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore age API mismatches
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when a drained New species tip would be allowed to take the camera.
|
|
/// </summary>
|
|
public static bool WouldAcceptCuriosity()
|
|
{
|
|
if (_current == null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
float onCurrent = Time.unscaledTime - _currentStartedAt;
|
|
float sinceSwitch = Time.unscaledTime - _lastSwitchAt;
|
|
return CanSwitchTo(
|
|
new InterestCandidate
|
|
{
|
|
Key = "probe:curiosity",
|
|
EventStrength = 40f,
|
|
LeadKind = InterestLeadKind.CharacterLed,
|
|
Completion = InterestCompletionKind.CharacterVignette,
|
|
TotalScore = 45f
|
|
},
|
|
onCurrent,
|
|
sinceSwitch);
|
|
}
|
|
|
|
public static bool SimulateManualInputExit(bool honorGrace = false)
|
|
{
|
|
if (!SpectatorMode.Active)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
MoveCamera.camera_drag_run = true;
|
|
bool detected = PlayerTookManualControl();
|
|
if (!detected)
|
|
{
|
|
MoveCamera.camera_drag_run = false;
|
|
return false;
|
|
}
|
|
|
|
if (honorGrace && Time.unscaledTime - _enabledAt < _inputExitGrace)
|
|
{
|
|
MoveCamera.camera_drag_run = false;
|
|
return false;
|
|
}
|
|
|
|
ExitFromManualInput();
|
|
MoveCamera.camera_drag_run = false;
|
|
return !SpectatorMode.Active;
|
|
}
|
|
|
|
private static void ExitFromManualInput()
|
|
{
|
|
SpectatorMode.SetActive(false, quiet: true);
|
|
WatchCaption.ShowStatusBanner("Paused (manual input)");
|
|
LogService.LogInfo("[IdleSpectator] Spectator paused (manual input)");
|
|
}
|
|
|
|
private static bool PlayerTookManualControl()
|
|
{
|
|
if (MoveCamera.camera_drag_run)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (Mathf.Abs(Input.mouseScrollDelta.y) > 0.01f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (HotkeyLibrary.left != null
|
|
&& (HotkeyLibrary.left.isHolding()
|
|
|| HotkeyLibrary.right.isHolding()
|
|
|| HotkeyLibrary.up.isHolding()
|
|
|| HotkeyLibrary.down.isHolding()))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (HotkeyLibrary.zoom_in != null
|
|
&& (HotkeyLibrary.zoom_in.isHolding() || HotkeyLibrary.zoom_out.isHolding()))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!World.world.isOverUI()
|
|
&& !WatchCaption.IsPointerOverInteractive()
|
|
&& !ChronicleHud.IsPointerOverPanel()
|
|
&& (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
|
|
{
|
|
// Grave skull click opens Lore; do not treat as idle pause.
|
|
if (InputHelpers.GetMouseButtonDown(0) && GraveMarkers.StackCount > 0)
|
|
{
|
|
try
|
|
{
|
|
WorldTile tile = World.world.getMouseTilePosCachedFrame() ?? World.world.getMouseTilePos();
|
|
if (tile != null && GraveMarkers.TryGetStack(tile.x, tile.y, out _))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Vector2 mouse = World.world.getMousePos();
|
|
if (GraveMarkers.FindNearestStack(mouse, 1.15f) != null)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// fall through to manual pause
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static float MinDwellFor(InterestCandidate current)
|
|
{
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds;
|
|
if (current == null)
|
|
{
|
|
return _minDwell < MinDwellSeconds * 0.5f ? _minDwell : floor;
|
|
}
|
|
|
|
// Ambient / Character fill: no event floor - real events may take the camera anytime.
|
|
if (IsAmbientShot(current))
|
|
{
|
|
if (_minDwell < MinDwellSeconds * 0.5f)
|
|
{
|
|
return InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell;
|
|
}
|
|
|
|
return w.dwellFill > 0f ? w.dwellFill : 6f;
|
|
}
|
|
|
|
float dwell = InterestScoring.IsHotScore(current.TotalScore)
|
|
? w.dwellHot
|
|
: w.dwellNormal;
|
|
if (_minDwell < MinDwellSeconds * 0.5f)
|
|
{
|
|
// Harness fast timing: keep compressed dwells.
|
|
dwell = _minDwell;
|
|
return current.MinWatch > 0f ? Mathf.Max(current.MinWatch, dwell) : dwell;
|
|
}
|
|
|
|
// Live EventLed: never peer-rotate / soft-cut before the camera floor.
|
|
// Score-margin interrupts bypass this via CanSwitchTo.
|
|
float minWatch = current.MinWatch > 0f ? current.MinWatch : 0f;
|
|
return Mathf.Max(floor, dwell, minWatch);
|
|
}
|
|
|
|
/// <summary>Character fill / low-score stroll - events should cut in freely.</summary>
|
|
private static bool IsAmbientShot(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (c.LeadKind == InterestLeadKind.CharacterLed
|
|
|| c.Completion == InterestCompletionKind.CharacterVignette)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Sticky event holds are never ambient even if score is temporarily fill-band.
|
|
if (c.Completion == InterestCompletionKind.CombatActive
|
|
|| c.Completion == InterestCompletionKind.StatusPhase
|
|
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
|
|| c.Completion == InterestCompletionKind.HappinessGrief
|
|
|| c.Completion == InterestCompletionKind.ActivityActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return InterestScoring.IsFillScore(c.TotalScore);
|
|
}
|
|
|
|
private static float MaxWatchFor(InterestCandidate current)
|
|
{
|
|
if (current == null)
|
|
{
|
|
return InterestScoringConfig.W.maxWatchMoment;
|
|
}
|
|
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
float classCap = w.maxWatchMoment;
|
|
switch (current.Completion)
|
|
{
|
|
case InterestCompletionKind.CombatActive:
|
|
classCap = w.maxWatchCombat;
|
|
break;
|
|
case InterestCompletionKind.WarFront:
|
|
case InterestCompletionKind.PlotActive:
|
|
case InterestCompletionKind.EarthquakeActive:
|
|
classCap = w.maxWatchCombat;
|
|
break;
|
|
case InterestCompletionKind.FamilyPack:
|
|
// Soft cluster - moment-class cap so packs cannot outlast unit events.
|
|
classCap = w.maxWatchMoment;
|
|
break;
|
|
case InterestCompletionKind.StatusPhase:
|
|
case InterestCompletionKind.StatusOutbreak:
|
|
classCap = w.maxWatchStatus;
|
|
break;
|
|
case InterestCompletionKind.HappinessGrief:
|
|
classCap = w.maxWatchGrief;
|
|
break;
|
|
case InterestCompletionKind.FixedDwell:
|
|
case InterestCompletionKind.Manual:
|
|
classCap = current.EventStrength >= w.hotScoreMin
|
|
? w.maxWatchEpic
|
|
: w.maxWatchMoment;
|
|
break;
|
|
default:
|
|
if (current.EventStrength >= w.hotScoreMin)
|
|
{
|
|
classCap = w.maxWatchEpic;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
float cap = classCap;
|
|
if (current.MaxWatch > 0f)
|
|
{
|
|
// Sticky live holds use the class MaxWatch (e.g. combat 60s) - do not let a
|
|
// short candidate MaxWatch max_cap the fight while it is still active.
|
|
if (current.Completion == InterestCompletionKind.CombatActive
|
|
|| current.Completion == InterestCompletionKind.WarFront
|
|
|| current.Completion == InterestCompletionKind.PlotActive
|
|
|| current.Completion == InterestCompletionKind.StatusPhase
|
|
|| current.Completion == InterestCompletionKind.StatusOutbreak
|
|
|| current.Completion == InterestCompletionKind.HappinessGrief
|
|
|| current.Completion == InterestCompletionKind.ActivityActive)
|
|
{
|
|
cap = Mathf.Max(current.MaxWatch, classCap);
|
|
}
|
|
else if (current.Completion == InterestCompletionKind.FamilyPack)
|
|
{
|
|
// Soft pack: honor the short candidate MaxWatch (do not stretch to combat caps).
|
|
cap = Mathf.Min(current.MaxWatch, classCap);
|
|
}
|
|
else
|
|
{
|
|
cap = Mathf.Min(current.MaxWatch, classCap);
|
|
}
|
|
}
|
|
|
|
// Moment / FixedDwell shots must last at least the camera floor unless interrupted.
|
|
if (current.Completion == InterestCompletionKind.FixedDwell
|
|
|| current.Completion == InterestCompletionKind.Manual)
|
|
{
|
|
float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds;
|
|
cap = Mathf.Max(cap, floor);
|
|
}
|
|
|
|
// Ambient fill: no minCameraDwell floor - short vignettes are fine.
|
|
if (IsAmbientShot(current))
|
|
{
|
|
float fillCap = w.dwellFill > 0f ? w.dwellFill * 2f : 20f;
|
|
if (current.MaxWatch > 0f)
|
|
{
|
|
fillCap = Mathf.Min(fillCap, Mathf.Max(current.MaxWatch, 8f));
|
|
}
|
|
|
|
cap = fillCap;
|
|
}
|
|
|
|
// Harness fast timing compresses caps.
|
|
if (_minDwell < MinDwellSeconds * 0.5f)
|
|
{
|
|
cap = Mathf.Min(cap, 8f);
|
|
}
|
|
|
|
return cap;
|
|
}
|
|
|
|
/// <summary>Hold while live and under MaxWatch; quiet grace still counts as briefly protected.</summary>
|
|
private static bool SessionProtected(float now, float onCurrent)
|
|
{
|
|
if (_current == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (onCurrent >= MaxWatchFor(_current))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
|
|
if (!active)
|
|
{
|
|
// Ambient: no minCameraDwell hold after the vignette goes cold.
|
|
if (!IsAmbientShot(_current) && onCurrent < MinDwellFor(_current))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (_inactiveSince >= 0f && now - _inactiveSince >= _quietGrace)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Still in quiet grace - treat as protected briefly.
|
|
return _inactiveSince < 0f || now - _inactiveSince < _quietGrace;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch)
|
|
{
|
|
if (candidate == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Same FixedDwell beat already watched on this subject - never cut back in.
|
|
if (InterestVariety.IsBeatCooled(candidate))
|
|
{
|
|
InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key);
|
|
return false;
|
|
}
|
|
|
|
if (_current == null)
|
|
{
|
|
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
|
|
// Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap.
|
|
if (IsCombatPartnerSwapNoise(_current, candidate))
|
|
{
|
|
InterestDropLog.Record(
|
|
"combat_partner_hold",
|
|
$"cur={_current.Key} next={candidate.Key}");
|
|
return false;
|
|
}
|
|
|
|
// War tip owns the camera against Mass/Battle scraps. Must run before crisis cut-in:
|
|
// a leftover war crisis can MatchCrisis(Mass) while a small WarFront tip does not.
|
|
if (_current.Completion == InterestCompletionKind.WarFront
|
|
&& candidate.Completion == InterestCompletionKind.CombatActive)
|
|
{
|
|
InterestDropLog.Record(
|
|
"war_theater_hold",
|
|
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
|
|
return false;
|
|
}
|
|
|
|
// Crisis-owned tips (esp. forced epilogue_crisis) may cut without score-margin wars
|
|
// against aftermath / ambient peers that outscore the closer on raw EventStrength.
|
|
if (StoryPlanner.CrisisActive
|
|
&& StoryPlanner.MatchesCrisis(candidate)
|
|
&& !StoryPlanner.MatchesCrisis(_current)
|
|
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Live fight: hold until cold (see who wins), unless something extremely urgent cuts in.
|
|
// Same-arc theater refresh / absorb still allowed. WarFront on the same kingdom
|
|
// theater may reclaim the chapter (Mass → War) without waiting for combat cold.
|
|
if (CombatFightIsHot(_current, now)
|
|
&& !IsSameStoryArc(_current, candidate)
|
|
&& candidate.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
bool warTheaterReclaim = candidate.Completion == InterestCompletionKind.WarFront
|
|
&& StickyScoreboard.SharesKingdomTheater(_current, candidate);
|
|
if (warTheaterReclaim)
|
|
{
|
|
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
|
|
}
|
|
|
|
if (IsCombatUrgentPeer(_current, candidate))
|
|
{
|
|
InterestDropLog.Record(
|
|
"combat_urgent_cut",
|
|
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
|
|
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
|
|
}
|
|
|
|
InterestDropLog.Record(
|
|
"combat_hold",
|
|
$"cur={_current.Key} next={candidate.Key}");
|
|
return false;
|
|
}
|
|
|
|
bool inGrace = InQuietGrace;
|
|
if (inGrace)
|
|
{
|
|
// Story aftermath / same-arc continuation may cut freely during quiet grace.
|
|
if (IsSameStoryArc(_current, candidate)
|
|
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
|
|
// Prevents family-join / love status from stealing a fight between swings.
|
|
if (_current != null
|
|
&& (_current.Completion == InterestCompletionKind.CombatActive
|
|
|| _current.Completion == InterestCompletionKind.StatusPhase
|
|
|| _current.Completion == InterestCompletionKind.HappinessGrief
|
|
|| IsStickyStoryScene(_current)))
|
|
{
|
|
float graceCur = _current.TotalScore;
|
|
float graceNext = candidate.TotalScore;
|
|
float onCurrentGrace = now - _currentStartedAt;
|
|
ScoringWeights gw = InterestScoringConfig.W;
|
|
float graceMargin = IsSoftStickyCluster(_current)
|
|
? gw.cutInMargin
|
|
: Mathf.Max(
|
|
gw.cutInMargin,
|
|
gw.stickyCutInMargin > 0f ? gw.stickyCutInMargin : 50f);
|
|
if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate))
|
|
{
|
|
float valveMargin = gw.stickyVarietyValveMargin > 0f
|
|
? gw.stickyVarietyValveMargin
|
|
: 20f;
|
|
graceMargin = Mathf.Min(graceMargin, valveMargin);
|
|
if (graceNext >= graceCur - gw.rotateSlack
|
|
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return graceNext >= graceCur + graceMargin
|
|
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
|
|
}
|
|
|
|
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
|
|
}
|
|
|
|
if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool protectedScene = SessionProtected(now, onCurrent);
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
float curScore = _current.TotalScore;
|
|
float nextScore = candidate.TotalScore;
|
|
bool nextFill = InterestScoring.IsFillScore(nextScore);
|
|
bool curAmbient = IsAmbientShot(_current);
|
|
bool curFill = curAmbient || InterestScoring.IsFillScore(curScore);
|
|
bool sticky = IsStickyStoryScene(_current);
|
|
float cutMargin = sticky
|
|
? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f)
|
|
: w.cutInMargin;
|
|
bool varietyValve = VarietyValveOpen(_current, onCurrent)
|
|
&& IsVarietyValveCandidate(candidate);
|
|
if (varietyValve)
|
|
{
|
|
float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f;
|
|
cutMargin = Mathf.Min(cutMargin, valveMargin);
|
|
}
|
|
|
|
// Ambient fill: any real event may take the camera immediately (no min dwell).
|
|
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Soft family pack: yield to discrete unit events after a short hold.
|
|
if (IsSoftStickyCluster(_current)
|
|
&& !IsSoftStickyCluster(candidate)
|
|
&& !nextFill
|
|
&& !IsAmbientShot(candidate)
|
|
&& onCurrent >= SoftClusterYieldSeconds
|
|
&& nextScore >= curScore - w.rotateSlack)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut.
|
|
// Score-margin alone can never clear Mass (~150) with lover (~78); use class gate.
|
|
if (varietyValve && !nextFill && !IsAmbientShot(candidate))
|
|
{
|
|
bool hotEnough = InterestScoring.IsHotScore(nextScore)
|
|
|| nextScore >= curScore - w.rotateSlack;
|
|
bool storyEnough = nextScore >= w.noticeScoreMin
|
|
|| candidate.EventStrength >= w.noticeScoreMin;
|
|
if (hotEnough || storyEnough)
|
|
{
|
|
InterestDropLog.Record(
|
|
"variety_valve",
|
|
$"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Same-arc refresh (combat/hatch/grief keys) may replace without full margin.
|
|
if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// StoryPlanner hard-arc / crisis hold against unrelated peers.
|
|
float storyHold = Mathf.Max(
|
|
StoryPlanner.ArcHoldMargin(_current, candidate),
|
|
StoryPlanner.CrisisHoldMargin(_current, candidate));
|
|
if (storyHold > 0f)
|
|
{
|
|
cutMargin = Mathf.Max(cutMargin, storyHold);
|
|
}
|
|
|
|
// Instant score-margin cut - no settle grace, no MinDwell block.
|
|
// Soft clusters use the normal cut-in margin (not stickyCutInMargin).
|
|
float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin;
|
|
if (nextScore >= curScore + effectiveMargin)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if ((sticky || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
|
|
{
|
|
InterestDropLog.Record(
|
|
"below_margin",
|
|
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
|
|
+ (varietyValve ? " valve" : "")
|
|
+ (storyHold > 0f ? " story" : ""));
|
|
}
|
|
|
|
// Fill never cuts a protected non-fill session without margin (already failed above).
|
|
if (nextFill && !curFill && protectedScene)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds;
|
|
if (nextFill && curFill && onCurrent < fillRotate)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Protected / sticky EventLed hold: only margin or same-arc (handled above).
|
|
if ((protectedScene || sticky) && !curAmbient)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Peer rotate / stronger score after MinDwell when unprotected or on fill.
|
|
if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return onCurrent >= MinDwellFor(_current)
|
|
&& sinceSwitch >= _switchCooldown
|
|
&& nextScore >= curScore - w.rotateSlack;
|
|
}
|
|
|
|
/// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
|
|
private const float SoftClusterYieldSeconds = 4f;
|
|
|
|
private static bool IsSoftStickyCluster(InterestCandidate c) =>
|
|
c != null && c.Completion == InterestCompletionKind.FamilyPack;
|
|
|
|
/// <summary>True while a CombatActive scene still has a live scrap (incl. hysteresis).</summary>
|
|
private static bool CombatFightIsHot(InterestCandidate scene, float now)
|
|
{
|
|
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (_harnessCombatForcedCold && scene == _current)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return InterestCompletion.IsActive(scene, _currentStartedAt, now);
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world
|
|
/// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers).
|
|
/// </summary>
|
|
private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next)
|
|
{
|
|
if (current == null || next == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f;
|
|
if (next.TotalScore >= current.TotalScore + stickyMargin)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// King / leadership deaths sit under the epic floor (king_killed=88) but must cut
|
|
// live scraps - soak missed a king slaying while parked on a Duel thrash.
|
|
if (IsLeadershipDeathPeer(next))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
float epicMin = w.combatUrgentEventStrengthMin > 0f
|
|
? w.combatUrgentEventStrengthMin
|
|
: 95f;
|
|
float urgentMargin = w.combatUrgentCutInMargin > 0f
|
|
? w.combatUrgentCutInMargin
|
|
: 20f;
|
|
return next.EventStrength >= epicMin
|
|
&& next.TotalScore >= current.TotalScore + urgentMargin;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers.
|
|
/// </summary>
|
|
private static bool IsLeadershipDeathPeer(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string id = (c.AssetId ?? "").ToLowerInvariant();
|
|
if (id.IndexOf("king_killed", StringComparison.Ordinal) >= 0
|
|
|| id.IndexOf("king_dead", StringComparison.Ordinal) >= 0
|
|
|| id.IndexOf("king_died", StringComparison.Ordinal) >= 0
|
|
|| id.IndexOf("leader_killed", StringComparison.Ordinal) >= 0
|
|
|| id.IndexOf("favorite_killed", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return id.IndexOf("kill", StringComparison.Ordinal) >= 0
|
|
|| id.IndexOf("dead", StringComparison.Ordinal) >= 0
|
|
|| id.IndexOf("died", StringComparison.Ordinal) >= 0
|
|
|| id.IndexOf("slain", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
|
|
/// Live CombatActive scraps never open the valve - hold until the fight goes cold.
|
|
/// </summary>
|
|
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
|
|
!_varietyValveUsed
|
|
&& VarietyValveTimeReady(scene, onCurrent)
|
|
&& !CombatFightIsHot(scene, Time.unscaledTime);
|
|
|
|
private static bool VarietyValveTimeReady(InterestCandidate scene, float onCurrent)
|
|
{
|
|
if (scene == null
|
|
|| (scene.Completion != InterestCompletionKind.CombatActive
|
|
&& scene.Completion != InterestCompletionKind.WarFront))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
float after = w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 16f;
|
|
if (onCurrent >= after)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
float maxW = MaxWatchFor(scene);
|
|
float frac = w.stickyVarietyValveMaxWatchFrac > 0f ? w.stickyVarietyValveMaxWatchFrac : 0.4f;
|
|
return maxW > 0f && onCurrent >= maxW * frac;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Peers allowed through the sticky variety valve (class rules, not tip strings).
|
|
/// </summary>
|
|
private static bool IsVarietyValveCandidate(InterestCandidate c)
|
|
{
|
|
if (c == null || InterestScoring.IsFillScore(c.TotalScore) || IsAmbientShot(c))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Never treat another fight/war as a "variety" peer - those use sticky margin / same-arc.
|
|
if (c.Completion == InterestCompletionKind.CombatActive
|
|
|| c.Completion == InterestCompletionKind.WarFront
|
|
|| string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(c.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (c.Completion == InterestCompletionKind.PlotActive
|
|
|| c.Completion == InterestCompletionKind.StatusOutbreak)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (InterestScoring.IsHotScore(c.TotalScore)
|
|
|| c.EventStrength >= InterestScoringConfig.W.hotScoreMin)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (c.EventStrength >= InterestScoringConfig.W.noticeScoreMin
|
|
&& c.LeadKind == InterestLeadKind.EventLed)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (IsParkableLifeBeat(c))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (c.Completion == InterestCompletionKind.StatusPhase
|
|
&& IsLethalStatusCandidate(c))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// New-species discovery Curiosity.
|
|
if (!string.IsNullOrEmpty(c.Key)
|
|
&& c.Key.StartsWith("species:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string label = c.Label ?? "";
|
|
return label.StartsWith("New species:", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsParkableLifeBeat(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string asset = c.AssetId ?? "";
|
|
string key = c.Key ?? "";
|
|
string happiness = c.HappinessEffectId ?? "";
|
|
if (asset.Equals("set_lover", StringComparison.OrdinalIgnoreCase)
|
|
|| asset.Equals("clear_lover", StringComparison.OrdinalIgnoreCase)
|
|
|| asset.Equals("find_lover", StringComparison.OrdinalIgnoreCase)
|
|
|| asset.Equals("fallen_in_love", StringComparison.OrdinalIgnoreCase)
|
|
|| happiness.Equals("fallen_in_love", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return key.StartsWith("rel:set_lover", StringComparison.OrdinalIgnoreCase)
|
|
|| key.StartsWith("rel:clear_lover", StringComparison.OrdinalIgnoreCase)
|
|
|| key.StartsWith("rel:find_lover", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsLethalStatusCandidate(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string id = c.StatusId ?? c.AssetId ?? "";
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return id.IndexOf("drown", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| id.IndexOf("burn", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| id.IndexOf("poison", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| id.IndexOf("frozen", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| id.IndexOf("curse", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| id.IndexOf("infect", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
}
|
|
|
|
private static bool IsStickyStoryScene(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster).
|
|
if (c.Completion == InterestCompletionKind.CombatActive
|
|
|| c.Completion == InterestCompletionKind.WarFront
|
|
|| c.Completion == InterestCompletionKind.PlotActive
|
|
|| c.Completion == InterestCompletionKind.StatusPhase
|
|
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
|
|| c.Completion == InterestCompletionKind.HappinessGrief)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Hatch FixedDwell is a story beat - hold against weak peers.
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId)
|
|
&& (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.Key)
|
|
&& (c.Key.StartsWith("combat:", StringComparison.Ordinal)
|
|
|| c.Key.StartsWith("hatch:", StringComparison.Ordinal)))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next)
|
|
{
|
|
if (current == null || next == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(current.Key)
|
|
&& !string.IsNullOrEmpty(next.Key)
|
|
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Hatch: same subject beat may refresh without full margin.
|
|
if (!string.IsNullOrEmpty(current.Key)
|
|
&& !string.IsNullOrEmpty(next.Key)
|
|
&& SameKeyPrefix(current.Key, next.Key, "hatch:"))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Combat: only the same unordered pair (or escalate of that scrap) is same-arc.
|
|
// Different combat:pair keys used to soft-cut and thrash Duel partners.
|
|
if (current.Completion == InterestCompletionKind.CombatActive
|
|
&& next.Completion == InterestCompletionKind.CombatActive
|
|
&& IsSameCombatPairArc(current, next))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Mass → War reclaim on the same kingdom pair (never War → Mass soft-cut).
|
|
if (current.Completion == InterestCompletionKind.CombatActive
|
|
&& next.Completion == InterestCompletionKind.WarFront
|
|
&& StickyScoreboard.SharesKingdomTheater(current, next))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (current.Completion == InterestCompletionKind.WarFront
|
|
&& next.Completion == InterestCompletionKind.WarFront
|
|
&& StickyScoreboard.SharesKingdomTheater(current, next))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (current.Completion == InterestCompletionKind.HappinessGrief
|
|
&& next.Completion == InterestCompletionKind.HappinessGrief
|
|
&& current.SubjectId != 0
|
|
&& current.SubjectId == next.SubjectId)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// StoryPlanner aftermath / epilogue of the active climax.
|
|
if (StoryPlanner.IsContinuationOf(current, next))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when next is the same 1v1 pair or a multi escalate of the current scrap.
|
|
/// </summary>
|
|
private static bool IsSameCombatPairArc(InterestCandidate current, InterestCandidate next)
|
|
{
|
|
if (current == null || next == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(current.Key)
|
|
&& !string.IsNullOrEmpty(next.Key)
|
|
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
CollectCombatActorIds(current, out long curA, out long curB);
|
|
CollectCombatActorIds(next, out long nextA, out long nextB);
|
|
bool samePair = curA != 0
|
|
&& curB != 0
|
|
&& nextA != 0
|
|
&& nextB != 0
|
|
&& ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA));
|
|
if (samePair)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Duel → Mass/Battle escalate: shared fighter + larger theater.
|
|
bool nextMulti = next.ParticipantCount >= 3
|
|
|| string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
|
|| HasStickyCollectiveMulti(next);
|
|
if (!nextMulti)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return SharesCombatFighterId(current, nextA)
|
|
|| SharesCombatFighterId(current, nextB)
|
|
|| SharesCombatFighterId(next, curA)
|
|
|| SharesCombatFighterId(next, curB);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold.
|
|
/// </summary>
|
|
private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next)
|
|
{
|
|
if (current == null
|
|
|| next == null
|
|
|| current.Completion != InterestCompletionKind.CombatActive
|
|
|| next.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Multi escalate / different theater is not partner-swap noise.
|
|
bool nextMulti = next.ParticipantCount >= 3
|
|
|| HasStickyCollectiveMulti(next)
|
|
|| (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
|
&& next.ParticipantCount > 2);
|
|
if (nextMulti && !IsThinPairCombat(next))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
CollectCombatActorIds(current, out long curA, out long curB);
|
|
CollectCombatActorIds(next, out long nextA, out long nextB);
|
|
if (curA == 0 && curB == 0
|
|
&& current.TheaterLeadId == 0
|
|
&& current.PairOwnerId == 0
|
|
&& current.SubjectId == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Mass/Battle held on a theater lead: peer Duel with a new attack_target is noise.
|
|
if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current))
|
|
{
|
|
if (IsThinPairCombat(next) && SharesCombatOwnerOrLead(current, nextA, nextB))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (!IsThinPairCombat(next))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Same unordered pair with flipped Follow (Duel A vs B ↔ B vs A) is ownership noise.
|
|
// Same-subject refreshes may still soft-cut; A↔B tip order must hold while both live.
|
|
bool sameUnorderedPair = curA != 0
|
|
&& curB != 0
|
|
&& nextA != 0
|
|
&& nextB != 0
|
|
&& ((curA == nextA && curB == nextB)
|
|
|| (curA == nextB && curB == nextA));
|
|
if (sameUnorderedPair)
|
|
{
|
|
long curSub = current.SubjectId != 0
|
|
? current.SubjectId
|
|
: (current.PairOwnerId != 0
|
|
? current.PairOwnerId
|
|
: EventFeedUtil.SafeId(current.FollowUnit));
|
|
long nextSub = next.SubjectId != 0
|
|
? next.SubjectId
|
|
: EventFeedUtil.SafeId(next.FollowUnit);
|
|
if (curSub != 0 && nextSub != 0 && curSub != nextSub)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Shared fighter + different partner = attack_target thrash.
|
|
return SharesCombatOwnerOrLead(current, nextA, nextB)
|
|
|| SharesCombatFighterId(current, nextA)
|
|
|| SharesCombatFighterId(current, nextB);
|
|
}
|
|
|
|
private static bool SharesCombatOwnerOrLead(InterestCandidate current, long nextA, long nextB)
|
|
{
|
|
if (current == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
long lead = current.TheaterLeadId != 0
|
|
? current.TheaterLeadId
|
|
: (current.PairOwnerId != 0 ? current.PairOwnerId : current.SubjectId);
|
|
if (lead == 0)
|
|
{
|
|
lead = EventFeedUtil.SafeId(current.FollowUnit);
|
|
}
|
|
|
|
return lead != 0 && (lead == nextA || lead == nextB);
|
|
}
|
|
|
|
private static bool IsThinPairCombat(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (c.HasCombatPairLock || c.ParticipantCount <= 2)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string tip = c.Label ?? "";
|
|
if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
|
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string key = c.Key ?? "";
|
|
return key.StartsWith("combat:pair:", StringComparison.Ordinal)
|
|
|| (key.StartsWith("combat:", StringComparison.Ordinal)
|
|
&& key.IndexOf(":pair:", StringComparison.Ordinal) < 0
|
|
&& !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b)
|
|
{
|
|
a = 0;
|
|
b = 0;
|
|
if (c == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId;
|
|
b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId;
|
|
if (a == 0)
|
|
{
|
|
a = EventFeedUtil.SafeId(c.FollowUnit);
|
|
}
|
|
|
|
if (b == 0)
|
|
{
|
|
b = EventFeedUtil.SafeId(c.RelatedUnit);
|
|
}
|
|
}
|
|
|
|
private static bool SharesCombatFighterId(InterestCandidate c, long id)
|
|
{
|
|
if (c == null || id == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
CollectCombatActorIds(c, out long a, out long b);
|
|
return id == a || id == b;
|
|
}
|
|
|
|
private static bool SameKeyPrefix(string a, string b, string prefix)
|
|
{
|
|
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!a.StartsWith(prefix, StringComparison.Ordinal)
|
|
|| !b.StartsWith(prefix, StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return string.Equals(a, b, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Still-worth-watching filter: drop stale queued moments; keep live sticky conditions
|
|
/// and fresh FixedDwell; during grace, brand-new fires are always eligible.
|
|
/// </summary>
|
|
private static bool IsWorthWatchingNow(InterestCandidate c, float now, bool selectingDuringGrace)
|
|
{
|
|
if (c == null || !c.HasValidPosition)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (selectingDuringGrace && c.CreatedAt >= _inactiveSince && _inactiveSince >= 0f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Purge moments that fired before the current scene started (stale queue).
|
|
// Parkable life beats may still wait for the variety valve under combat/war.
|
|
if (_current != null && c.CreatedAt < _currentStartedAt - 0.05f)
|
|
{
|
|
if (IsParkableLifeBeat(c)
|
|
&& (_current.Completion == InterestCompletionKind.CombatActive
|
|
|| _current.Completion == InterestCompletionKind.WarFront)
|
|
&& now - c.CreatedAt < 45f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (c.Completion == InterestCompletionKind.FixedDwell
|
|
|| c.Completion == InterestCompletionKind.Manual)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Sticky: only if still live.
|
|
return InterestCompletion.IsActive(c, c.CreatedAt, now);
|
|
}
|
|
|
|
if (IsStaleMomentCandidate(c, now))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
switch (c.Completion)
|
|
{
|
|
case InterestCompletionKind.CombatActive:
|
|
case InterestCompletionKind.StatusPhase:
|
|
case InterestCompletionKind.HappinessGrief:
|
|
case InterestCompletionKind.ActivityActive:
|
|
return InterestCompletion.IsActive(c, c.CreatedAt, now)
|
|
|| now - c.LastSeenAt < 2f;
|
|
case InterestCompletionKind.FixedDwell:
|
|
case InterestCompletionKind.Manual:
|
|
// Fresh moment: age under ~4s or more than half MaxWatch remaining.
|
|
float age = now - c.CreatedAt;
|
|
if (age < 4f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (IsParkableLifeBeat(c)
|
|
&& _current != null
|
|
&& (_current.Completion == InterestCompletionKind.CombatActive
|
|
|| _current.Completion == InterestCompletionKind.WarFront)
|
|
&& age < 45f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
float maxW = c.MaxWatch > 0f ? c.MaxWatch : InterestScoringConfig.W.maxWatchMoment;
|
|
return age < maxW * 0.5f;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private static bool IsStaleMomentCandidate(InterestCandidate c, float now)
|
|
{
|
|
if (IsStaleFreshLifeMoment(c, now))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (c.ExpiresAt > 0f && now > c.ExpiresAt)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Queued under an active hold: short TTL for FixedDwell moments.
|
|
// Life beats (lover / bond) park longer under sticky combat/war so the variety
|
|
// valve can still cut them in instead of soft-dropping forever.
|
|
if (_current != null
|
|
&& CurrentIsActive
|
|
&& (c.Completion == InterestCompletionKind.FixedDwell || c.Completion == InterestCompletionKind.Manual)
|
|
&& now - c.CreatedAt > 8f)
|
|
{
|
|
bool parkLife = IsParkableLifeBeat(c)
|
|
&& (_current.Completion == InterestCompletionKind.CombatActive
|
|
|| _current.Completion == InterestCompletionKind.WarFront);
|
|
if (parkLife)
|
|
{
|
|
return now - c.CreatedAt > 45f;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
|
|
{
|
|
if (next == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Same scene object: maintain already owns focus - do not re-Watch / re-log.
|
|
if (next == _current)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!EventPresentability.WouldShow(next)
|
|
&& !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
InterestDropLog.Record("unpresentable", next.Label ?? next.Key);
|
|
InterestRegistry.Remove(next.Key);
|
|
return;
|
|
}
|
|
|
|
if (_current != null
|
|
&& _current.Resumable
|
|
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin
|
|
// Already-shown moment beats are cooled - parking them only enables resume spam.
|
|
&& !InterestVariety.IsBeatCooled(_current, now))
|
|
{
|
|
_interrupted = _current.CloneShallow();
|
|
}
|
|
|
|
// Consume the once-per-hold variety valve when leaving sticky combat/war for a peer story.
|
|
bool leavingStickyCombat = _current != null
|
|
&& (_current.Completion == InterestCompletionKind.CombatActive
|
|
|| _current.Completion == InterestCompletionKind.WarFront);
|
|
bool nextIsCombatTheater = next.Completion == InterestCompletionKind.CombatActive
|
|
|| next.Completion == InterestCompletionKind.WarFront;
|
|
if (leavingStickyCombat
|
|
&& !nextIsCombatTheater
|
|
&& IsVarietyValveCandidate(next)
|
|
&& VarietyValveTimeReady(_current, now - _currentStartedAt))
|
|
{
|
|
_varietyValveUsed = true;
|
|
}
|
|
|
|
if (nextIsCombatTheater && !leavingStickyCombat)
|
|
{
|
|
_varietyValveUsed = false;
|
|
}
|
|
|
|
_current = next;
|
|
_currentStartedAt = now;
|
|
_lastSwitchAt = now;
|
|
_inactiveSince = -999f;
|
|
_harnessCombatForcedCold = false;
|
|
if (!InterestScoring.IsFillScore(next.TotalScore))
|
|
{
|
|
_lastInterestingAt = now;
|
|
}
|
|
|
|
InterestRegistry.MarkSelected(next.Key);
|
|
InterestVariety.NoteSelection(next);
|
|
StoryPlanner.OnSwitchTo(next, now);
|
|
CameraDirector.Watch(next.ToInterestEvent());
|
|
}
|
|
|
|
private static void TryCharacterFill(bool force)
|
|
{
|
|
float now = Time.unscaledTime;
|
|
_lastAmbientAt = now;
|
|
|
|
// Crisis chapters own the camera - no ambient channel-surfing mid-war/disaster.
|
|
if (StoryPlanner.SuppressAmbientFill)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!force && _current != null && SessionProtected(now, now - _currentStartedAt)
|
|
&& InterestScoring.IsHotScore(_current.TotalScore))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!force && _current != null && InterestScoring.IsFillScore(_current.TotalScore)
|
|
&& now - _currentStartedAt < _ambientRotate)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!force && now - _lastSwitchAt < _switchCooldown && _current != null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget();
|
|
if (ambient == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Character fill never owns an orange reason - empty Label + CharacterLed.
|
|
// Force fill score so RegisterDirect does not publish as EventLed.
|
|
ambient.Label = "";
|
|
float fillCap = InterestScoringConfig.W.fillScoreMax > 0f
|
|
? InterestScoringConfig.W.fillScoreMax * 0.5f
|
|
: 20f;
|
|
if (ambient.Score > fillCap || AgentHarness.Busy)
|
|
{
|
|
ambient.Score = fillCap;
|
|
}
|
|
|
|
InterestCandidate candidate = InterestFeeds.RegisterDirect(ambient);
|
|
if (candidate == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
candidate.LeadKind = InterestLeadKind.CharacterLed;
|
|
candidate.Category = "CharacterVignette";
|
|
candidate.Completion = InterestCompletionKind.CharacterVignette;
|
|
// Fill is not an event - empty orange reason; task chip shows live job.
|
|
candidate.Label = "";
|
|
if (AgentHarness.Busy)
|
|
{
|
|
candidate.ForceActive = false;
|
|
candidate.EventStrength = Mathf.Min(candidate.EventStrength, 25f);
|
|
InterestScoring.ScoreCheap(candidate);
|
|
}
|
|
|
|
if (!force
|
|
&& _current != null
|
|
&& _current.FollowUnit != null
|
|
&& candidate.FollowUnit != null
|
|
&& _current.FollowUnit == candidate.FollowUnit
|
|
&& InterestScoring.IsFillScore(candidate.TotalScore))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!force && !CanSwitchTo(candidate, now - _currentStartedAt, now - _lastSwitchAt))
|
|
{
|
|
return;
|
|
}
|
|
|
|
SwitchTo(candidate, now, resumableInterrupt: false);
|
|
LogService.LogInfo("[IdleSpectator] Ambient: " + candidate.Label);
|
|
}
|
|
}
|