worldbox-observer-mod/IdleSpectator/InterestDirector.cs
DazedAnon 739cc6492c feat(spectator): make narrative threads own camera direction
- Route confirmed life events into evidence-based threads, consequences, and Legacy chapters
- Schedule episode shots with critical interrupts, replay guards, and a combat budget
- Admit emerging main characters through story momentum instead of spectacle
- Remove causal heat, hard-arc memory writes, and synthetic epilogue continuity
- Expand narrative harness coverage, soak auditing, and product documentation
2026-07-23 12:17:18 -05:00

2880 lines
94 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;
}
// Solo status tips: only the live carrier owns the orange reason.
// Stops Prefer/MC soft-follow from showing "Becomes pregnant" on a king who lacks it.
if (scene.Completion == InterestCompletionKind.StatusPhase
&& !string.IsNullOrEmpty(scene.StatusId)
&& !StatusGameApi.HasStatus(unit, scene.StatusId))
{
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;
/// <summary>Camera read-pause lease owners (hover preview). Feeds/story continue; switching deferred.</summary>
private static readonly HashSet<string> ReadPauseOwners = new HashSet<string>();
private static bool _readPauseNeedsReconcile;
public static bool ReadPauseActive => ReadPauseOwners.Count > 0;
public static bool AcquireReadPause(string owner)
{
if (string.IsNullOrEmpty(owner))
{
return false;
}
bool added = ReadPauseOwners.Add(owner);
return added || ReadPauseOwners.Contains(owner);
}
public static void ReleaseReadPause(string owner)
{
if (string.IsNullOrEmpty(owner))
{
return;
}
if (ReadPauseOwners.Remove(owner) && ReadPauseOwners.Count == 0)
{
_readPauseNeedsReconcile = true;
}
}
public static void ClearReadPause()
{
if (ReadPauseOwners.Count == 0)
{
return;
}
ReadPauseOwners.Clear();
_readPauseNeedsReconcile = true;
}
public static void OnSpectatorEnabled()
{
ClearBrowseFreeze();
ClearReadPause();
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();
ClearReadPause();
_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;
IdleHitchProbe.Mark("feeds");
InterestFeeds.Tick();
InterestRegistry.ExpireStale(now);
IdleHitchProbe.Mark("feeds_done");
}
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
IdleHitchProbe.Mark("story");
StoryPlanner.Tick(now);
LifeSagaRoster.Tick(now);
LifeSagaSession.EnsureBound();
StoryScheduler.Tick(now);
IdleHitchProbe.Mark("story_done");
if (ReadPauseActive)
{
// Keep feeds/story/roster alive; freeze every camera-mutating path.
return;
}
if (_readPauseNeedsReconcile)
{
_readPauseNeedsReconcile = false;
MaintainCameraFocus(now);
}
UpdateSessionLiveness(now, onCurrent);
MaintainCombatFocus(now, force: false);
MaintainWarFront(now, force: false);
MaintainPlotCell(now, force: false);
MaintainFamilyPack(now, force: false);
MaintainStatusOutbreak(now, force: false);
IdleHitchProbe.Mark("select");
InterestCandidate next = SelectNext(now);
IdleHitchProbe.Mark("select_done");
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);
}
// Soft crumbs ending during/after hard quiet must not reopen the floodgates.
if (ended != null && InterestVariety.IsSoftFillQuietCrumb(ended))
{
StoryPlanner.NoteSoftCrumbQuietRefresh(now);
}
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;
CameraDirector.ClearWatchReason();
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;
}
if (!StoryPlanner.HasLiveStoryOwner(c))
{
InterestDropLog.Record("story_orphan", c.CorrelationKey ?? c.Key);
InterestRegistry.Remove(c.Key);
PendingScratch.RemoveAt(i);
continue;
}
// After hard story/crisis closers, skip soft-life / short interrupt crumbs
// so AFK does not immediately hatch/reproduce/sleep/stun surf. Leave them pending.
if (softQuiet
&& InterestVariety.IsSoftFillQuietCrumb(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;
}
InterestCandidate storyChoice = StoryScheduler.SelectForCamera(PendingScratch, _current, now);
if (storyChoice != null)
{
return storyChoice;
}
if (StoryScheduler.ShouldHoldForEpisode(now))
{
return null;
}
// When no episode owns a usable development, select bounded ambient/world context.
// EnrichTopK runs inside InterestVariety.Pick once - avoid a second top-K meta walk here.
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;
}
// FamilyPack is ambient cluster fill - must not break soft-fill quiet.
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront
|| c.Completion == InterestCompletionKind.PlotActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| c.Completion == InterestCompletionKind.EarthquakeActive)
{
return true;
}
if (string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase)
|| StoryReason.IsStoryAsset(c.AssetId))
{
return true;
}
// Prefer / MC life tips may reclaim the camera during soft-fill quiet.
if (LifeSagaRoster.TipTouchesPrefer(c) || LifeSagaRoster.TipTouchesMc(c))
{
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)
{
StoryScheduler.NoteActualSelection(next);
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);
}
}