- Introduce variety valve mechanics for cut-in opportunities during combat - Improve fight balance calculations and scoring based on participant counts - Add new scenarios for testing variety valve interactions and combat focus - Update combat event handling to prioritize live fighters over bystanders - Increment version to 0.27.6 in mod.json
3777 lines
127 KiB
C#
3777 lines
127 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;
|
|
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;
|
|
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).
|
|
/// Sticky combat may briefly report !IsActive between swings - keep the reason up.
|
|
/// </summary>
|
|
public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit)
|
|
{
|
|
if (unit == null || _current == null || InQuietGrace)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
long unitId = 0;
|
|
try
|
|
{
|
|
unitId = unit.getID();
|
|
}
|
|
catch
|
|
{
|
|
unitId = 0;
|
|
}
|
|
|
|
if (_current.FollowUnit == unit)
|
|
{
|
|
return _current;
|
|
}
|
|
|
|
if (_current.SubjectId != 0 && unitId != 0 && _current.SubjectId == unitId)
|
|
{
|
|
return _current;
|
|
}
|
|
|
|
if (_current.RelatedUnit == unit)
|
|
{
|
|
return _current;
|
|
}
|
|
|
|
if (_current.RelatedId != 0 && unitId != 0 && _current.RelatedId == unitId)
|
|
{
|
|
return _current;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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: 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();
|
|
_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();
|
|
}
|
|
|
|
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;
|
|
|
|
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".
|
|
if (onCurrent >= MaxWatchFor(_current))
|
|
{
|
|
EndCurrent(now, reason: "max_cap");
|
|
return;
|
|
}
|
|
|
|
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
|
|
if (active)
|
|
{
|
|
_inactiveSince = -999f;
|
|
TryHandoffFollow();
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
bool pairOwned = IsNamedPairCombatOwnership(_current)
|
|
|| (_current.HasCombatPairLock
|
|
&& ownedFocus != null
|
|
&& ownedFocus.isAlive()
|
|
&& ownedRelated != null
|
|
&& ownedRelated.isAlive()
|
|
&& !HasStickyCollectiveMulti(_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);
|
|
// Both left the scrap (chores / idle): do not hold a Duel camera on them.
|
|
// Keep durable A vs B order while either still fights (no tip flip on attack gaps).
|
|
if (!focusFighting && !relatedFighting)
|
|
{
|
|
// Fall through to sticky rebuild / live fighter pick.
|
|
}
|
|
else if (!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());
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
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
|
|
{
|
|
// Leaving NamedPair for a sided multi: drop durable pair lock so later maintains
|
|
// cannot ApplyNamedPairHold and collapse Mass/Battle back to Duel.
|
|
if (pairOwned
|
|
&& fighters >= 3
|
|
&& (ensemble != null && LiveEnsemble.HasOpposingCollectiveSides(ensemble)
|
|
|| HasStickyCollectiveMulti(_current)))
|
|
{
|
|
_current.PairOwnerId = 0;
|
|
_current.PairPartnerId = 0;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Never replace a sticky scoreboard tip with thin Fight prose.
|
|
if (HasStickyCombatSides(_current)
|
|
&& (string.IsNullOrEmpty(label)
|
|
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0))
|
|
{
|
|
var stickyEns = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.Combat,
|
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)),
|
|
Focus = best,
|
|
Related = foe,
|
|
Frame = _current.CombatSideFrame,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = _current.CombatSideAKey,
|
|
Display = _current.CombatSideADisplay,
|
|
KingdomDisplay = _current.CombatSideAKingdom,
|
|
Count = _current.CombatSideACount,
|
|
Best = best
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = _current.CombatSideBKey,
|
|
Display = _current.CombatSideBDisplay,
|
|
KingdomDisplay = _current.CombatSideBKingdom,
|
|
Count = _current.CombatSideBCount,
|
|
Best = foe
|
|
},
|
|
ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount
|
|
};
|
|
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;
|
|
}
|
|
|
|
bool followChanged = _current.FollowUnit != best;
|
|
bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal);
|
|
bool headcountOnly = labelChanged
|
|
&& EventReason.IsHeadcountOnlyChange(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-only Mass tip churn: keep Label for live reason refresh, skip Watch
|
|
// (RetargetFollow + tip log) when the camera subject is already correct.
|
|
bool needWatch = forceWatch
|
|
|| followChanged
|
|
|| (labelChanged && !headcountOnly)
|
|
|| !HasLivingCameraFocus()
|
|
|| MoveCamera._focus_unit != best;
|
|
if (needWatch)
|
|
{
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
}
|
|
|
|
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);
|
|
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;
|
|
}
|
|
|
|
return scene.CombatPeakParticipants >= 3
|
|
|| string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <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>
|
|
/// 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);
|
|
|
|
// Already-framed collective multi may leave NamedPair, unless a Duel tip is still
|
|
// holding ownership over ambient sticky pollution (needs a live sided ensemble).
|
|
if (HasStickyCollectiveMulti(scene) && !duelLabeled)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (fighters < 3 || ensemble == null || !LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Natural growth: both pair members still fighting inside a sided multi cluster.
|
|
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;
|
|
}
|
|
|
|
return LiveEnsemble.IsCombatParticipant(focus)
|
|
&& LiveEnsemble.IsCombatParticipant(related);
|
|
}
|
|
|
|
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 ambient species/kingdom sticky that leaked in during cluster Refresh.
|
|
if (HasStickyCombatSides(scene)
|
|
&& (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;
|
|
}
|
|
|
|
// Collective multi (including Duel mid-escalate after sticky Refresh) leaves the pair.
|
|
if (fighters >= 3 && HasStickyCollectiveMulti(scene))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (HasStickyCollectiveMulti(scene))
|
|
{
|
|
string tip = scene.Label ?? "";
|
|
if (!tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool duelLabeled = !string.IsNullOrEmpty(scene.Label)
|
|
&& scene.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
|
|
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 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
|
|
|| _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>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;
|
|
}
|
|
|
|
// Leaving NamedPair: clear durable pair lock so maintain may keep collective framing.
|
|
if (authoredN >= 3 || LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
|
{
|
|
_current.PairOwnerId = 0;
|
|
_current.PairPartnerId = 0;
|
|
}
|
|
|
|
_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.Combat(ensemble);
|
|
try
|
|
{
|
|
_current.Position = ensemble.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();
|
|
_lastCombatFocusAt = now;
|
|
CameraDirector.Watch(_current.ToInterestEvent());
|
|
}
|
|
|
|
/// <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 (_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)
|
|
{
|
|
if (_current != null)
|
|
{
|
|
InterestRegistry.Remove(_current.Key);
|
|
LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label);
|
|
}
|
|
|
|
// Resume interrupted Epic-preempted scene if still valid.
|
|
if (_interrupted != null
|
|
&& _interrupted.HasValidPosition
|
|
&& InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now))
|
|
{
|
|
InterestCandidate resume = _interrupted;
|
|
_interrupted = null;
|
|
SwitchTo(resume, now, resumableInterrupt: false);
|
|
return;
|
|
}
|
|
|
|
_interrupted = null;
|
|
_current = null;
|
|
_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)))
|
|
{
|
|
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:
|
|
case InterestCompletionKind.WarFront:
|
|
case InterestCompletionKind.PlotActive:
|
|
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;
|
|
bool inGrace = InQuietGrace;
|
|
if (inGrace)
|
|
{
|
|
// 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;
|
|
float graceMargin = Mathf.Max(
|
|
InterestScoringConfig.W.cutInMargin,
|
|
InterestScoringConfig.W.stickyCutInMargin > 0f
|
|
? InterestScoringConfig.W.stickyCutInMargin
|
|
: 50f);
|
|
if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate))
|
|
{
|
|
float valveMargin = InterestScoringConfig.W.stickyVarietyValveMargin > 0f
|
|
? InterestScoringConfig.W.stickyVarietyValveMargin
|
|
: 20f;
|
|
graceMargin = Mathf.Min(graceMargin, valveMargin);
|
|
if (graceNext >= graceCur - InterestScoringConfig.W.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;
|
|
}
|
|
|
|
// 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 && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
|
|
{
|
|
InterestDropLog.Record(
|
|
"below_margin",
|
|
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
|
|
+ (varietyValve ? " valve" : ""));
|
|
}
|
|
|
|
// 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>
|
|
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
|
|
/// </summary>
|
|
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
|
|
!_varietyValveUsed && VarietyValveTimeReady(scene, onCurrent);
|
|
|
|
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 : 20f;
|
|
if (onCurrent >= after)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
float maxW = MaxWatchFor(scene);
|
|
float frac = w.stickyVarietyValveMaxWatchFrac > 0f ? w.stickyVarietyValveMaxWatchFrac : 0.5f;
|
|
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;
|
|
}
|
|
|
|
// Combat / hatch share key prefixes per subject.
|
|
if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key))
|
|
{
|
|
if (SameKeyPrefix(current.Key, next.Key, "combat:")
|
|
|| SameKeyPrefix(current.Key, next.Key, "hatch:"))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (current.Completion == InterestCompletionKind.HappinessGrief
|
|
&& next.Completion == InterestCompletionKind.HappinessGrief
|
|
&& current.SubjectId != 0
|
|
&& current.SubjectId == next.SubjectId)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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)
|
|
{
|
|
_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;
|
|
if (!InterestScoring.IsFillScore(next.TotalScore))
|
|
{
|
|
_lastInterestingAt = now;
|
|
}
|
|
|
|
InterestRegistry.MarkSelected(next.Key);
|
|
InterestVariety.NoteSelection(next);
|
|
CameraDirector.Watch(next.ToInterestEvent());
|
|
}
|
|
|
|
private static void TryCharacterFill(bool force)
|
|
{
|
|
float now = Time.unscaledTime;
|
|
_lastAmbientAt = now;
|
|
|
|
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);
|
|
}
|
|
}
|