1238 lines
38 KiB
C#
1238 lines
38 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 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
_current.FollowUnit = null;
|
|
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);
|
|
|
|
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())
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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.HasFollowUnit)
|
|
{
|
|
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 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;
|
|
}
|
|
|
|
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 shots from the pending set.
|
|
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|
|
|| IsStaleFreshLifeMoment(c, now))
|
|
{
|
|
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.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.StatusPhase:
|
|
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.StatusPhase
|
|
|| current.Completion == InterestCompletionKind.HappinessGrief
|
|
|| current.Completion == InterestCompletionKind.ActivityActive)
|
|
{
|
|
cap = Mathf.Max(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;
|
|
}
|
|
|
|
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 graceMargin = Mathf.Max(
|
|
InterestScoringConfig.W.cutInMargin,
|
|
InterestScoringConfig.W.stickyCutInMargin > 0f
|
|
? InterestScoringConfig.W.stickyCutInMargin
|
|
: 50f);
|
|
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;
|
|
|
|
// Ambient fill: any real event may take the camera immediately (no min dwell).
|
|
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
|
|
{
|
|
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.
|
|
if (nextScore >= curScore + cutMargin)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (sticky && nextScore < curScore + cutMargin)
|
|
{
|
|
InterestDropLog.Record(
|
|
"below_margin",
|
|
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}");
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
private static bool IsStickyStoryScene(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (c.Completion == InterestCompletionKind.CombatActive
|
|
|| c.Completion == InterestCompletionKind.StatusPhase
|
|
|| 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).
|
|
if (_current != null && c.CreatedAt < _currentStartedAt - 0.05f)
|
|
{
|
|
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;
|
|
}
|
|
|
|
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.
|
|
if (_current != null
|
|
&& CurrentIsActive
|
|
&& (c.Completion == InterestCompletionKind.FixedDwell || c.Completion == InterestCompletionKind.Manual)
|
|
&& now - c.CreatedAt > 8f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
|
|
{
|
|
if (next == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_current != null
|
|
&& _current.Resumable
|
|
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin)
|
|
{
|
|
_interrupted = _current.CloneShallow();
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
// Harness batches inject their own candidates; never let a live Action/Story
|
|
// ambient fill steal the camera before trigger_interest / discovery steps.
|
|
if (AgentHarness.Busy && ambient.Score >= InterestScoringConfig.W.noticeScoreMin)
|
|
{
|
|
ambient.Score = InterestScoringConfig.W.fillScoreMax * 0.5f;
|
|
if (string.IsNullOrEmpty(ambient.Label))
|
|
{
|
|
ambient.Label = "Ambient";
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|