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