worldbox-observer-mod/IdleSpectator/InterestDirector.cs
DazedAnon 750a303d89 Implement variety valve for combat interactions and enhance scoring.
- Introduce variety valve mechanics for cut-in opportunities during combat
- Improve fight balance calculations and scoring based on participant counts
- Add new scenarios for testing variety valve interactions and combat focus
- Update combat event handling to prioritize live fighters over bystanders
- Increment version to 0.27.6 in mod.json
2026-07-17 15:09:19 -05:00

3777 lines
127 KiB
C#

using System;
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Event-centered scene director: registry intake, protected sessions, score-margin
/// preemption with typed combat/vignette rules, soft 70/30 variety, completion via
/// <see cref="InterestCompletion.IsActive"/>.
/// </summary>
public static class InterestDirector
{
public const float MinDwellSeconds = 15f;
public const float CuriosityDwellSeconds = 6f;
public const float CuriosityRotateSeconds = 10f;
public const float SwitchCooldownSeconds = 5f;
public const float HighTierInterruptAfterSeconds = 3f;
public const float QuietWorldAmbientAfterSeconds = 8f;
public const float AmbientRotateSeconds = 20f;
public const float ActionPollSeconds = 0.75f;
public const float InputExitGraceSeconds = 2.5f;
public const float QuietGraceSeconds = 6f;
public const float CameraSettleGraceSeconds = 3f;
private static float _minDwell = MinDwellSeconds;
private static float _curiosityDwell = CuriosityDwellSeconds;
private static float _curiosityRotate = CuriosityRotateSeconds;
private static float _switchCooldown = SwitchCooldownSeconds;
private static float _settleGrace = CameraSettleGraceSeconds;
private static float _quietAmbientAfter = QuietWorldAmbientAfterSeconds;
private static float _ambientRotate = AmbientRotateSeconds;
private static float _feedsTick = ActionPollSeconds;
private static float _inputExitGrace = InputExitGraceSeconds;
private static float _quietGrace = QuietGraceSeconds;
private static InterestCandidate _current;
private static InterestCandidate _interrupted;
private static float _currentStartedAt = -999f;
private static float _lastSwitchAt = -999f;
private static float _lastInterestingAt = -999f;
private static float _lastAmbientAt = -999f;
private static float _lastFeedsAt = -999f;
private static float _enabledAt = -999f;
private static float _inactiveSince = -999f;
private static float _lastCombatFocusAt = -999f;
/// <summary>One Story/lover/discovery cut per sticky combat/war hold via the variety valve.</summary>
private static bool _varietyValveUsed;
private const float CombatFocusThrottleSeconds = 1.25f;
/// <summary>Large Mass sticky scenes can maintain less often - roster work is heavier.</summary>
private const float CombatFocusThrottleLargeSeconds = 2.0f;
private const int CombatFocusLargeParticipantThreshold = 16;
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
private const float CombatFocusSwitchMargin = 12f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
public static string CurrentTierName => CurrentScoreLabel;
public static string CurrentScoreLabel =>
_current == null ? "none" : _current.TotalScore.ToString("0.#");
public static float CurrentScore =>
_current == null ? 0f : _current.TotalScore;
public static string CurrentLabel =>
_current == null ? "" : (_current.Label ?? "");
public static string CurrentKey =>
_current == null ? "" : (_current.Key ?? "");
public static bool CurrentIsActive
{
get
{
if (_current == null)
{
return false;
}
return InterestCompletion.IsActive(_current, _currentStartedAt, Time.unscaledTime);
}
}
public static InterestCandidate CurrentCandidate => _current;
/// <summary>
/// True while the current scene is past completion but still held through quiet_grace
/// (camera may remain; orange reason must be empty).
/// </summary>
public static bool InQuietGrace
{
get
{
if (_current == null || CurrentIsActive)
{
return false;
}
return _inactiveSince >= 0f;
}
}
/// <summary>
/// Candidate that may drive the orange dossier reason: owns <paramref name="unit"/>
/// while the director still holds that scene (not quiet_grace).
/// Sticky combat may briefly report !IsActive between swings - keep the reason up.
/// </summary>
public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit)
{
if (unit == null || _current == null || InQuietGrace)
{
return null;
}
long unitId = 0;
try
{
unitId = unit.getID();
}
catch
{
unitId = 0;
}
if (_current.FollowUnit == unit)
{
return _current;
}
if (_current.SubjectId != 0 && unitId != 0 && _current.SubjectId == unitId)
{
return _current;
}
if (_current.RelatedUnit == unit)
{
return _current;
}
if (_current.RelatedId != 0 && unitId != 0 && _current.RelatedId == unitId)
{
return _current;
}
return null;
}
public static string InterruptedKey =>
_interrupted == null ? "" : (_interrupted.Key ?? "");
public static InterestLeadKind? CurrentLeadKind =>
_current == null ? (InterestLeadKind?)null : _current.LeadKind;
public static bool InInputGrace =>
SpectatorMode.Active && Time.unscaledTime - _enabledAt < _inputExitGrace;
public static void SetHarnessFastTiming(bool enabled)
{
if (enabled)
{
_minDwell = 1.5f;
_curiosityDwell = 1.2f;
_curiosityRotate = 1.5f;
_switchCooldown = 0.75f;
_settleGrace = 0.75f;
_quietAmbientAfter = 1.5f;
_ambientRotate = 2.5f;
_feedsTick = 0.35f;
_inputExitGrace = 0.6f;
_quietGrace = 0.35f;
}
else
{
_minDwell = MinDwellSeconds;
_curiosityDwell = CuriosityDwellSeconds;
_curiosityRotate = CuriosityRotateSeconds;
_switchCooldown = SwitchCooldownSeconds;
_settleGrace = CameraSettleGraceSeconds;
_quietAmbientAfter = QuietWorldAmbientAfterSeconds;
_ambientRotate = AmbientRotateSeconds;
_feedsTick = ActionPollSeconds;
_inputExitGrace = InputExitGraceSeconds;
_quietGrace = QuietGraceSeconds;
}
}
public static void HarnessAgeCurrent(float seconds)
{
float age = Mathf.Max(0f, seconds);
float now = Time.unscaledTime;
_currentStartedAt = now - age;
_lastSwitchAt = now - age;
}
public static void HarnessExpireInputGrace()
{
_enabledAt = Time.unscaledTime - _inputExitGrace - 0.05f;
}
/// <summary>Harness: force the given candidate as the active session.</summary>
public static bool HarnessForceSession(InterestCandidate candidate)
{
if (candidate == null || !candidate.HasValidPosition)
{
return false;
}
if (candidate.Completion == InterestCompletionKind.CombatActive)
{
candidate.ClearCombatSticky();
}
EventFeedUtil.RegisterCandidate(candidate);
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
return true;
}
/// <summary>Harness: end the active scene (may resume an Epic-interrupted one).</summary>
public static void HarnessEndCurrent(string reason = "harness")
{
EndCurrent(Time.unscaledTime, reason);
}
/// <summary>Harness: drop ForceActive so FixedDwell / quiet grace can finish the scene.</summary>
public static void HarnessReleaseForceActive()
{
if (_current != null)
{
_current.ForceActive = false;
}
}
/// <summary>
/// Harness: mark the current scene inactive for <paramref name="inactiveSeconds"/>
/// so quiet-grace completion can be asserted.
/// </summary>
public static void HarnessMarkInactive(float inactiveSeconds)
{
if (_current == null)
{
return;
}
_current.ForceActive = false;
if (_current.Completion == InterestCompletionKind.Manual)
{
_current.Completion = InterestCompletionKind.FixedDwell;
}
float now = Time.unscaledTime;
// Age past FixedDwell / Manual MaxWatch so IsActive is false.
float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 0.5f;
_currentStartedAt = now - pastActive;
_lastSwitchAt = _currentStartedAt;
_inactiveSince = now - Mathf.Max(0f, inactiveSeconds);
}
/// <summary>Harness: clear FollowUnit while keeping RelatedUnit for death handoff.</summary>
public static bool HarnessClearFollowForHandoff(Actor related)
{
if (_current == null)
{
return false;
}
if (related != null && related.isAlive())
{
_current.RelatedUnit = related;
try
{
_current.RelatedId = related.getID();
}
catch
{
_current.RelatedId = 0;
}
}
if (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())
{
return false;
}
// Transfer durable pair ownership to the handoff unit so maintain does not restore the old Follow.
long newOwner = EventFeedUtil.SafeId(_current.RelatedUnit);
long oldOwner = _current.PairOwnerId;
_current.PairOwnerId = newOwner;
_current.PairPartnerId = oldOwner != 0 && oldOwner != newOwner ? oldOwner : 0;
_current.FollowUnit = null;
_current.SubjectId = 0;
return true;
}
/// <summary>Harness: run one SelectNext without switching (lead-kind probe).</summary>
public static InterestCandidate HarnessPeekNext()
{
return SelectNext(Time.unscaledTime);
}
public static void OnSpectatorEnabled()
{
InterestRegistry.Clear();
InterestVariety.Clear();
InterestScoring.ClearCache();
InterestFeeds.Reset();
_current = null;
_interrupted = null;
float now = Time.unscaledTime;
_currentStartedAt = now;
_lastSwitchAt = now;
_lastInterestingAt = now;
_lastAmbientAt = -999f;
_lastFeedsAt = -999f;
_enabledAt = now;
_inactiveSince = -999f;
// Harness scenarios inject their own candidates after enable/focus.
if (!AgentHarness.Busy)
{
TryCharacterFill(force: true);
}
}
public static void OnSpectatorDisabled()
{
_current = null;
_interrupted = null;
InterestRegistry.Clear();
}
public static void PauseForBrowsing(string banner = "Paused (viewing history)")
{
if (SpectatorMode.Active)
{
SpectatorMode.SetActive(false, quiet: true);
}
WatchCaption.PinWhilePaused();
if (!string.IsNullOrEmpty(banner))
{
WatchCaption.ShowStatusBanner(banner);
}
LogService.LogInfo("[IdleSpectator] Spectator paused (viewing history)");
}
public static void Update()
{
if (!SpectatorMode.Active || !Config.game_loaded || World.world == null)
{
return;
}
WatchCaption.ClearPausePin();
if (AgentHarness.FreezeDirector)
{
return;
}
if (Time.unscaledTime - _enabledAt >= _inputExitGrace
&& PlayerTookManualControl())
{
ExitFromManualInput();
return;
}
float now = Time.unscaledTime;
if (now - _lastFeedsAt >= _feedsTick)
{
_lastFeedsAt = now;
InterestFeeds.Tick();
InterestRegistry.ExpireStale(now);
}
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
UpdateSessionLiveness(now, onCurrent);
MaintainCombatFocus(now, force: false);
MaintainWarFront(now, force: false);
MaintainPlotCell(now, force: false);
MaintainFamilyPack(now, force: false);
MaintainStatusOutbreak(now, force: false);
InterestCandidate next = SelectNext(now);
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
{
if (!InterestScoring.IsFillScore(next.TotalScore))
{
_lastInterestingAt = now;
}
SwitchTo(next, now, resumableInterrupt: false);
onCurrent = 0f;
sinceSwitch = 0f;
}
// Pending events own the camera - never Character-fill over them.
if (InterestRegistry.HasPendingEvent())
{
// Still repair a missing follow so death mid-scene does not flash the power bar.
MaintainCameraFocus(now);
return;
}
bool quiet = now - _lastInterestingAt >= _quietAmbientAfter;
bool sceneDone = _current == null || !SessionProtected(now, onCurrent);
bool ambientDue = now - _lastAmbientAt >= _ambientRotate;
if (_current == null && sinceSwitch >= 0.5f)
{
TryCharacterFill(force: true);
}
else if (sceneDone && quiet && ambientDue && sinceSwitch >= _switchCooldown)
{
TryCharacterFill(force: false);
}
MaintainCameraFocus(now);
}
private static void UpdateSessionLiveness(float now, float onCurrent)
{
if (_current == null)
{
_inactiveSince = -999f;
return;
}
// Max cap ends the scene even if still "active".
if (onCurrent >= MaxWatchFor(_current))
{
EndCurrent(now, reason: "max_cap");
return;
}
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (active)
{
_inactiveSince = -999f;
TryHandoffFollow();
return;
}
// No living subject left: prefer sticky combat roster handoff before ending.
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
if (_current.Completion == InterestCompletionKind.CombatActive
&& StickyScoreboard.TryPromoteFollow(_current))
{
MaintainCombatFocus(now, force: true);
return;
}
if (_current.Completion == InterestCompletionKind.WarFront
&& StickyScoreboard.TryPromoteFollow(_current))
{
MaintainWarFront(now, force: true);
return;
}
if (_current.Completion == InterestCompletionKind.PlotActive
&& StickyScoreboard.TryPromoteFollow(_current))
{
MaintainPlotCell(now, force: true);
return;
}
if (_current.Completion == InterestCompletionKind.FamilyPack
&& StickyScoreboard.TryPromoteFollow(_current))
{
MaintainFamilyPack(now, force: true);
return;
}
if (_current.Completion == InterestCompletionKind.StatusOutbreak
&& StickyScoreboard.TryPromoteFollow(_current))
{
MaintainStatusOutbreak(now, force: true);
return;
}
EndCurrent(now, reason: "follow_lost");
return;
}
// Ambient: end promptly when the vignette goes cold - do not hold for minCameraDwell.
if (IsAmbientShot(_current))
{
if (_inactiveSince < 0f)
{
_inactiveSince = now;
}
if (now - _inactiveSince >= _quietGrace)
{
EndCurrent(now, reason: "quiet_grace");
}
return;
}
// Condition ended early: still hold the camera until min dwell, then grace.
if (onCurrent < MinDwellFor(_current))
{
_inactiveSince = -999f;
return;
}
if (_inactiveSince < 0f)
{
_inactiveSince = now;
}
if (now - _inactiveSince >= _quietGrace)
{
EndCurrent(now, reason: "quiet_grace");
}
}
private static void TryHandoffFollow()
{
if (_current == null)
{
return;
}
if (_current.Completion == InterestCompletionKind.CombatActive)
{
MaintainCombatFocus(Time.unscaledTime, force: true);
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
if (StickyScoreboard.TryPromoteFollow(_current))
{
MaintainCombatFocus(Time.unscaledTime, force: true);
return;
}
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
return;
}
if (_current.Completion == InterestCompletionKind.WarFront)
{
MaintainWarFront(Time.unscaledTime, force: true);
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
if (StickyScoreboard.TryPromoteFollow(_current))
{
MaintainWarFront(Time.unscaledTime, force: true);
return;
}
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
return;
}
if (_current.Completion == InterestCompletionKind.PlotActive)
{
MaintainPlotCell(Time.unscaledTime, force: true);
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
if (StickyScoreboard.TryPromoteFollow(_current))
{
MaintainPlotCell(Time.unscaledTime, force: true);
return;
}
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
return;
}
if (_current.Completion == InterestCompletionKind.FamilyPack)
{
MaintainFamilyPack(Time.unscaledTime, force: true);
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
if (StickyScoreboard.TryPromoteFollow(_current))
{
MaintainFamilyPack(Time.unscaledTime, force: true);
return;
}
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
return;
}
if (_current.Completion == InterestCompletionKind.StatusOutbreak)
{
MaintainStatusOutbreak(Time.unscaledTime, force: true);
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
if (StickyScoreboard.TryPromoteFollow(_current))
{
MaintainStatusOutbreak(Time.unscaledTime, force: true);
return;
}
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
return;
}
if (_current.HasFollowUnit)
{
// Living subject still owned - reattach if vanilla cleared focus mid-scene.
if (!HasLivingCameraFocus() || MoveCamera._focus_unit != _current.FollowUnit)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return;
}
// Ownership-preserving handoff only: related survivor. Never invent a stranger.
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
_current.FollowUnit = _current.RelatedUnit;
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
CameraDirector.Watch(_current.ToInterestEvent());
return;
}
// No owned related: end the scene rather than attach a nearby stranger.
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
/// <summary>
/// Keep camera on the highest-scored combat participant and rewrite tip/counts
/// from a live <see cref="LiveEnsemble"/> so subject + scale always match focus.
/// Clears fight Labels once combat goes cold.
/// </summary>
private static void MaintainCombatFocus(float now, bool force)
{
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
{
return;
}
bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (!combatHot)
{
ClearStaleCombatLabel(now);
return;
}
float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold
? CombatFocusThrottleLargeSeconds
: CombatFocusThrottleSeconds;
if (!force && now - _lastCombatFocusAt < throttle)
{
return;
}
_lastCombatFocusAt = now;
ApplyCombatEnsembleToCurrent(forceWatch: false);
}
/// <summary>
/// Refresh active combat scene from the world ensemble. Returns false if no focus.
/// </summary>
private static bool ApplyCombatEnsembleToCurrent(bool forceWatch)
{
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
// Snapshot durable pair ownership before cluster rebuild can rewrite Follow+Related.
ResolveCombatPairActors(_current, out Actor ownedFocus, out Actor ownedRelated);
if (ownedFocus != null)
{
_current.StampCombatPair(ownedFocus, ownedRelated);
}
bool pairOwned = IsNamedPairCombatOwnership(_current)
|| (_current.HasCombatPairLock
&& ownedFocus != null
&& ownedFocus.isAlive()
&& ownedRelated != null
&& ownedRelated.isAlive()
&& !HasStickyCollectiveMulti(_current));
// Fast path: living NamedPair skips sticky Refresh (ambient camps were flipping Duels).
// Probe the local scrap first so a real sided multi can escalate Duel → Skirmish/Battle/Mass.
// Skip when Follow was cleared (death / harness handoff) so Related can take ownership.
if (pairOwned
&& _current.HasFollowUnit
&& ownedFocus != null
&& ownedFocus.isAlive()
&& ownedRelated != null
&& ownedRelated.isAlive())
{
LiveEnsemble.TryBuildCombat(
ownedFocus.current_position,
14f,
out LiveEnsemble escalateProbe,
priorParticipantIds: _current.ParticipantIds,
newcomerBonus: 8f);
int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2;
bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus);
bool relatedFighting = LiveEnsemble.IsCombatParticipant(ownedRelated);
// Both left the scrap (chores / idle): do not hold a Duel camera on them.
// Keep durable A vs B order while either still fights (no tip flip on attack gaps).
if (!focusFighting && !relatedFighting)
{
// Fall through to sticky rebuild / live fighter pick.
}
else if (!ShouldEscalateNamedPair(_current, escalateProbe, probeFighters))
{
string pairLabel = "";
Actor holdFocus = ownedFocus;
Actor holdFoe = ownedRelated;
int holdFighters = 2;
ApplyNamedPairHold(
_current, ownedFocus, ownedRelated, ref holdFocus, ref holdFoe, ref holdFighters, ref pairLabel);
string priorLabel = _current.Label ?? "";
if (string.IsNullOrEmpty(pairLabel))
{
pairLabel = priorLabel;
}
bool holdFollowChanged = _current.FollowUnit != holdFocus;
bool holdLabelChanged = !string.Equals(priorLabel, pairLabel ?? "", StringComparison.Ordinal);
_current.FollowUnit = holdFocus;
_current.SubjectId = EventFeedUtil.SafeId(holdFocus);
_current.RelatedUnit = holdFoe;
_current.RelatedId = holdFoe != null ? EventFeedUtil.SafeId(holdFoe) : 0;
_current.Label = pairLabel;
try
{
_current.Position = holdFocus.current_position;
}
catch
{
// keep
}
bool holdNeedWatch = forceWatch
|| holdFollowChanged
|| holdLabelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != holdFocus;
if (holdNeedWatch)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return true;
}
// Real multi around the pair: fall through to sticky rebuild / collective tip.
}
Vector3 pos = _current.Position;
if (ownedFocus != null && ownedFocus.isAlive())
{
pos = ownedFocus.current_position;
}
else if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
{
pos = _current.FollowUnit.current_position;
}
else if (ownedRelated != null && ownedRelated.isAlive())
{
pos = ownedRelated.current_position;
}
else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
pos = _current.RelatedUnit.current_position;
}
bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| _current.ParticipantCount > 2
|| HasStickyCombatSides(_current);
float radius = massHint ? 14f : 10f;
// Death / clear-follow handoff: promote Related before cluster re-pick can steal focus.
if (!_current.HasFollowUnit
&& _current.RelatedUnit != null
&& _current.RelatedUnit.isAlive())
{
Actor handoff = _current.RelatedUnit;
Actor handoffFoe = null;
try
{
if (handoff.has_attack_target
&& handoff.attack_target != null
&& handoff.attack_target.isAlive()
&& handoff.attack_target.isActor())
{
handoffFoe = handoff.attack_target.a;
if (handoffFoe != null && !handoffFoe.isAlive())
{
handoffFoe = null;
}
}
}
catch
{
handoffFoe = null;
}
LiveEnsemble.TryBuildCombat(
handoff.current_position,
radius,
out LiveEnsemble handoffEnsemble,
priorParticipantIds: _current.ParticipantIds,
newcomerBonus: 8f);
if (handoffEnsemble != null)
{
StickyScoreboard.StabilizeScale(_current.Sticky, handoffEnsemble, Time.unscaledTime);
ApplyEnsembleFields(_current, handoffEnsemble);
StickyScoreboard.Refresh(
_current, handoffEnsemble, handoff.current_position, radius);
}
// Keep ownership on the handoff unit; only use ensemble framing when the melee is large.
string handoffLabel;
if (handoffEnsemble != null
&& (handoffEnsemble.ParticipantCount > 2
|| StickyScoreboard.HasOpposingSides(_current)
|| handoffEnsemble.Scale != EnsembleScale.Pair))
{
handoffEnsemble.Focus = handoff;
if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff)
{
handoffEnsemble.Related = handoffFoe;
}
handoffLabel = EventReason.Combat(handoffEnsemble);
}
else
{
// NamedPair handoff: keep Duel framing with the other pair id when possible.
if (handoffFoe == null || !handoffFoe.isAlive())
{
long handoffId = EventFeedUtil.SafeId(handoff);
long otherId = _current.PairOwnerId != 0 && _current.PairOwnerId != handoffId
? _current.PairOwnerId
: _current.PairPartnerId;
if (otherId != 0 && otherId != handoffId)
{
handoffFoe = LiveEnsemble.FindTrackedActor(otherId);
}
}
var pairHandoff = new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Scale = EnsembleScale.Pair,
Frame = EnsembleFrame.NamedPair,
ParticipantCount = 2,
Focus = handoff,
Related = handoffFoe
};
handoffLabel = EventReason.Combat(pairHandoff);
if (string.IsNullOrEmpty(handoffLabel))
{
handoffLabel = EventReason.Fight(handoff, handoffFoe);
}
_current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount);
}
bool handoffFollowChanged = _current.FollowUnit != handoff;
bool handoffLabelChanged = !string.Equals(_current.Label ?? "", handoffLabel ?? "", StringComparison.Ordinal);
_current.FollowUnit = handoff;
_current.SubjectId = EventFeedUtil.SafeId(handoff);
_current.RelatedUnit = handoffFoe;
_current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0;
_current.StampCombatPair(handoff, handoffFoe);
_current.Label = handoffLabel;
try
{
_current.Position = handoff.current_position;
}
catch
{
// keep
}
if (forceWatch
|| handoffFollowChanged
|| handoffLabelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != handoff)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return true;
}
LiveEnsemble.TryBuildCombat(
pos,
radius,
out LiveEnsemble ensemble,
priorParticipantIds: _current.ParticipantIds,
newcomerBonus: 8f);
Actor best;
Actor foe;
int fighters;
string label;
if (ensemble != null && ensemble.HasFocus)
{
StickyScoreboard.StabilizeScale(_current.Sticky, ensemble, Time.unscaledTime);
best = ensemble.Focus;
foe = ensemble.Related;
fighters = Mathf.Max(0, ensemble.ParticipantCount);
ApplyEnsembleFields(_current, ensemble);
StickyScoreboard.Refresh(_current, ensemble, pos, radius);
label = EventReason.Combat(ensemble);
}
else if (WorldActivityScanner.TryPickBestCombatFocus(
_current, out best, out foe, out fighters))
{
var fallback = new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Scale = LiveEnsemble.ScaleForCount(fighters),
Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
ParticipantCount = fighters,
Focus = best,
Related = foe
};
StickyScoreboard.StabilizeScale(_current.Sticky, fallback, Time.unscaledTime);
ApplyEnsembleFields(_current, fallback);
StickyScoreboard.Refresh(_current, fallback, pos, radius);
label = EventReason.Combat(fallback);
_current.ParticipantCount = Math.Max(fighters, _current.CombatSideACount + _current.CombatSideBCount);
}
else if (StickyScoreboard.HasOpposingSides(_current))
{
// No live fighters briefly - keep framed tip from sticky roster until combat goes cold.
if (!_current.HasFollowUnit)
{
StickyScoreboard.TryPromoteFollow(_current);
}
var held = new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)),
Focus = _current.FollowUnit,
Related = _current.RelatedUnit,
ParticipantCount = 0
};
StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime);
StickyScoreboard.Refresh(_current, held, pos, radius);
if (held.Focus == null || !held.Focus.isAlive())
{
held.Focus = _current.RelatedUnit;
}
if ((held.Focus == null || !held.Focus.isAlive()) && StickyScoreboard.TryPromoteFollow(_current))
{
held.Focus = _current.FollowUnit;
held.Related = _current.RelatedUnit;
StickyScoreboard.Refresh(_current, held, pos, radius);
}
if (held.Focus == null || !held.Focus.isAlive())
{
return false;
}
best = held.Focus;
foe = held.Related;
label = EventReason.Combat(held);
fighters = _current.CombatSideACount + _current.CombatSideBCount;
}
else
{
return false;
}
// Prefer the ownership snapshot over any Follow/Related rewrite during Refresh.
Actor curFollow = ownedFocus != null && ownedFocus.isAlive()
? ownedFocus
: _current.FollowUnit;
Actor curRelated = ownedRelated != null && ownedRelated.isAlive()
? ownedRelated
: _current.RelatedUnit;
// Named-pair lock: ambient nearby camps must not steal / flip a living Duel.
// Only a true multi escalate (or intentional sticky Battle) may leave the pair.
if (pairOwned
&& curFollow != null
&& curFollow.isAlive()
&& !ShouldEscalateNamedPair(_current, ensemble, fighters))
{
ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label);
}
else
{
// Leaving NamedPair for a sided multi: drop durable pair lock so later maintains
// cannot ApplyNamedPairHold and collapse Mass/Battle back to Duel.
if (pairOwned
&& fighters >= 3
&& (ensemble != null && LiveEnsemble.HasOpposingCollectiveSides(ensemble)
|| HasStickyCollectiveMulti(_current)))
{
_current.PairOwnerId = 0;
_current.PairPartnerId = 0;
}
// Prefer live fighters over rostered bystanders (Idle / Following / Breeding / chores).
bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
bool bestFighting = LiveEnsemble.IsCombatParticipant(best);
if (!curFighting)
{
if (bestFighting)
{
foe = ResolveAttackFoe(best) ?? foe;
}
else if (TryRetargetCombatFollow(_current, out Actor fighter, out Actor fighterFoe))
{
best = fighter;
foe = fighterFoe ?? foe;
if (ensemble != null)
{
label = EventReason.Combat(ensemble);
}
}
else if (curFollow != null && curFollow.isAlive())
{
// Nobody fighting - keep ownership rather than jump to a random sleeper.
best = curFollow;
foe = ResolveAttackFoe(best) ?? curRelated ?? foe;
}
}
else if (!bestFighting)
{
best = curFollow;
foe = ResolveAttackFoe(best) ?? foe;
}
else if (best != curFollow
&& curFollow != null
&& curFollow.isAlive()
&& !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
{
float curW = LiveEnsemble.FocusWeight(
curFollow, _current.ParticipantIds, newcomerBonus: 0f);
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
if (newW < curW + CombatFocusSwitchMargin)
{
best = curFollow;
if (foe == null || foe == best)
{
foe = ResolveAttackFoe(best) ?? foe;
}
}
}
// Preserve duel partner when rebuild briefly loses Related (attack_target gaps).
bool duelLabeled = !string.IsNullOrEmpty(_current.Label)
&& _current.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
if (foe == null
&& curRelated != null
&& curRelated.isAlive()
&& curRelated != best
&& (duelLabeled || _current.ForceActive || fighters <= 2))
{
foe = curRelated;
}
// Final pair ownership lock: Duel tips must not flip A↔B across maintain ticks.
if (ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
{
ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label);
}
}
// Never replace a sticky scoreboard tip with thin Fight prose.
if (HasStickyCombatSides(_current)
&& (string.IsNullOrEmpty(label)
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0))
{
var stickyEns = new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)),
Focus = best,
Related = foe,
Frame = _current.CombatSideFrame,
SideA = new EnsembleSide
{
Key = _current.CombatSideAKey,
Display = _current.CombatSideADisplay,
KingdomDisplay = _current.CombatSideAKingdom,
Count = _current.CombatSideACount,
Best = best
},
SideB = new EnsembleSide
{
Key = _current.CombatSideBKey,
Display = _current.CombatSideBDisplay,
KingdomDisplay = _current.CombatSideBKingdom,
Count = _current.CombatSideBCount,
Best = foe
},
ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount
};
label = EventReason.Combat(stickyEns);
}
string previousLabel = _current.Label ?? "";
if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(previousLabel))
{
label = previousLabel;
}
// Never drop the duel partner while the tip is still a Duel - empty Related
// makes the next maintain flip ownership to a thin "X is fighting" tip.
if (foe == null
&& curRelated != null
&& curRelated.isAlive()
&& curRelated != best
&& !string.IsNullOrEmpty(label)
&& label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase))
{
foe = curRelated;
}
bool followChanged = _current.FollowUnit != best;
bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal);
bool headcountOnly = labelChanged
&& EventReason.IsHeadcountOnlyChange(previousLabel, label ?? "");
_current.FollowUnit = best;
_current.SubjectId = EventFeedUtil.SafeId(best);
_current.RelatedUnit = foe;
_current.RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0;
_current.Label = label;
try
{
_current.Position = best.current_position;
}
catch
{
// keep prior position
}
// Headcount-only Mass tip churn: keep Label for live reason refresh, skip Watch
// (RetargetFollow + tip log) when the camera subject is already correct.
bool needWatch = forceWatch
|| followChanged
|| (labelChanged && !headcountOnly)
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != best;
if (needWatch)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return true;
}
/// <summary>
/// Refresh sticky war tip (kingdom populations + follow) while WarFront completion is hot.
/// </summary>
private static bool MaintainWarFront(float now, bool force)
{
if (_current == null || _current.Completion != InterestCompletionKind.WarFront)
{
return false;
}
if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds)
{
return _current.HasFollowUnit;
}
_lastCombatFocusAt = now;
return ApplyWarFrontMaintain(forceWatch: force);
}
private static bool ApplyWarFrontMaintain(bool forceWatch)
{
if (_current == null || _current.Completion != InterestCompletionKind.WarFront)
{
return false;
}
if (!_current.HasFollowUnit)
{
StickyScoreboard.TryPromoteFollow(_current);
}
if (!_current.HasFollowUnit)
{
return false;
}
Vector3 pos = _current.Position;
try
{
pos = _current.FollowUnit.current_position;
}
catch
{
// keep
}
var held = new LiveEnsemble
{
Kind = EnsembleKind.WarFront,
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)),
Frame = EnsembleFrame.KingdomVsKingdom,
Focus = _current.FollowUnit,
Related = _current.RelatedUnit,
ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount
};
StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime);
StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.WarTheaterRadius);
if (held.Focus != null && held.Focus.isAlive())
{
_current.FollowUnit = held.Focus;
_current.SubjectId = EventFeedUtil.SafeId(held.Focus);
}
if (held.Related != null && held.Related.isAlive())
{
_current.RelatedUnit = held.Related;
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
}
string label = EventReason.WarFront(held);
if (string.IsNullOrEmpty(label))
{
label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit);
}
if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label))
{
label = _current.Label;
}
bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus;
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
_current.Label = label ?? "";
_current.AssetId = "live_war";
try
{
if (_current.FollowUnit != null)
{
_current.Position = _current.FollowUnit.current_position;
}
}
catch
{
// keep
}
bool needWatch = forceWatch
|| followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit);
if (needWatch && _current.HasFollowUnit)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return _current.HasFollowUnit;
}
/// <summary>
/// Refresh sticky plot tip (plotter roster + target kingdom) while PlotActive is hot.
/// </summary>
private static bool MaintainPlotCell(float now, bool force)
{
if (_current == null || _current.Completion != InterestCompletionKind.PlotActive)
{
return false;
}
if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds)
{
return _current.HasFollowUnit;
}
_lastCombatFocusAt = now;
return ApplyPlotCellMaintain(forceWatch: force);
}
private static bool ApplyPlotCellMaintain(bool forceWatch)
{
if (_current == null || _current.Completion != InterestCompletionKind.PlotActive)
{
return false;
}
if (!_current.HasFollowUnit)
{
StickyScoreboard.TryPromoteFollow(_current);
}
if (!_current.HasFollowUnit)
{
return false;
}
Vector3 pos = _current.Position;
try
{
pos = _current.FollowUnit.current_position;
}
catch
{
// keep
}
var held = new LiveEnsemble
{
Kind = EnsembleKind.PlotCell,
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)),
Frame = EnsembleFrame.KingdomVsKingdom,
Focus = _current.FollowUnit,
Related = _current.RelatedUnit,
ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount,
SideA = new EnsembleSide
{
Key = _current.CombatSideAKey,
Display = "Plotters",
Count = _current.CombatSideACount,
Best = _current.FollowUnit
},
SideB = new EnsembleSide
{
Key = _current.CombatSideBKey,
Display = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey),
KingdomDisplay = LiveEnsemble.KingdomDisplay(_current.CombatSideBKey),
Count = _current.CombatSideBCount,
Best = _current.RelatedUnit
}
};
StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime);
StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.PlotTheaterRadius);
if (held.Focus != null && held.Focus.isAlive())
{
_current.FollowUnit = held.Focus;
_current.SubjectId = EventFeedUtil.SafeId(held.Focus);
}
if (held.Related != null && held.Related.isAlive())
{
_current.RelatedUnit = held.Related;
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
}
// Plot stickies are groups only - never "Plot - Plotters (1)" / (0).
int plotters = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
if (plotters < 2)
{
_current.Label = "";
_current.AssetId = "live_plot";
return _current.HasFollowUnit;
}
string label = EventReason.PlotCell(held);
if (string.IsNullOrEmpty(label))
{
label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit);
}
if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label))
{
label = _current.Label;
}
bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus;
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
_current.Label = label ?? "";
_current.AssetId = "live_plot";
try
{
if (_current.FollowUnit != null)
{
_current.Position = _current.FollowUnit.current_position;
}
}
catch
{
// keep
}
bool needWatch = forceWatch
|| followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit);
if (needWatch && _current.HasFollowUnit)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return _current.HasFollowUnit;
}
/// <summary>
/// Refresh sticky family pack tip (roster + alpha promote) while FamilyPack is hot.
/// </summary>
private static bool MaintainFamilyPack(float now, bool force)
{
if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack)
{
return false;
}
if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds)
{
return _current.HasFollowUnit;
}
_lastCombatFocusAt = now;
return ApplyFamilyPackMaintain(forceWatch: force);
}
private static bool ApplyFamilyPackMaintain(bool forceWatch)
{
if (_current == null || _current.Completion != InterestCompletionKind.FamilyPack)
{
return false;
}
if (!_current.HasFollowUnit)
{
StickyScoreboard.TryPromoteFollow(_current);
}
if (!_current.HasFollowUnit)
{
return false;
}
Vector3 pos = _current.Position;
try
{
pos = _current.FollowUnit.current_position;
}
catch
{
// keep
}
var held = new LiveEnsemble
{
Kind = EnsembleKind.FamilyPack,
Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)),
Frame = EnsembleFrame.SpeciesVsSpecies,
Focus = _current.FollowUnit,
Related = _current.RelatedUnit,
ParticipantCount = _current.CombatSideACount,
SideA = new EnsembleSide
{
Key = _current.CombatSideAKey,
Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay)
? LiveEnsemble.SpeciesDisplay(LiveEnsemble.SpeciesKeyOf(_current.FollowUnit))
: _current.Sticky.SideADisplay,
Count = _current.CombatSideACount,
Best = _current.FollowUnit
}
};
StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime);
StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.FamilyTheaterRadius);
if (held.Focus != null && held.Focus.isAlive())
{
_current.FollowUnit = held.Focus;
_current.SubjectId = EventFeedUtil.SafeId(held.Focus);
}
if (held.Related != null && held.Related.isAlive())
{
_current.RelatedUnit = held.Related;
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
}
string label = EventReason.FamilyPack(held);
if (string.IsNullOrEmpty(label))
{
label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit);
}
if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label))
{
label = _current.Label;
}
bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus;
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
_current.Label = label ?? "";
_current.AssetId = "live_family";
try
{
if (_current.FollowUnit != null)
{
_current.Position = _current.FollowUnit.current_position;
}
}
catch
{
// keep
}
bool needWatch = forceWatch
|| followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit);
if (needWatch && _current.HasFollowUnit)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return _current.HasFollowUnit;
}
/// <summary>
/// Refresh sticky status outbreak tip while StatusOutbreak completion is hot.
/// </summary>
private static bool MaintainStatusOutbreak(float now, bool force)
{
if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak)
{
return false;
}
if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds)
{
return _current.HasFollowUnit;
}
_lastCombatFocusAt = now;
return ApplyStatusOutbreakMaintain(forceWatch: force);
}
private static bool ApplyStatusOutbreakMaintain(bool forceWatch)
{
if (_current == null || _current.Completion != InterestCompletionKind.StatusOutbreak)
{
return false;
}
if (!_current.HasFollowUnit)
{
StickyScoreboard.TryPromoteFollow(_current);
}
if (!_current.HasFollowUnit)
{
return false;
}
Vector3 pos = _current.Position;
try
{
pos = _current.FollowUnit.current_position;
}
catch
{
// keep
}
string statusId = _current.StatusId ?? "";
if (string.IsNullOrEmpty(statusId)
&& LiveEnsemble.IsStatusOutbreakSideKey(_current.CombatSideAKey))
{
statusId = _current.CombatSideAKey.Substring("status:".Length);
_current.StatusId = statusId;
}
var held = new LiveEnsemble
{
Kind = EnsembleKind.StatusOutbreak,
Scale = LiveEnsemble.ScaleForCount(Math.Max(2, _current.CombatSideACount)),
Frame = EnsembleFrame.SpeciesVsSpecies,
Focus = _current.FollowUnit,
Related = _current.RelatedUnit,
ParticipantCount = _current.CombatSideACount,
SideA = new EnsembleSide
{
Key = string.IsNullOrEmpty(_current.CombatSideAKey)
? "status:" + statusId
: _current.CombatSideAKey,
Display = string.IsNullOrEmpty(_current.Sticky?.SideADisplay)
? LiveEnsemble.StatusDisplayName(statusId)
: _current.Sticky.SideADisplay,
Count = _current.CombatSideACount,
Best = _current.FollowUnit
}
};
StickyScoreboard.StabilizeScale(_current.Sticky, held, Time.unscaledTime);
StickyScoreboard.Refresh(_current, held, pos, StickyScoreboard.StatusOutbreakRadius);
if (held.Focus != null && held.Focus.isAlive())
{
_current.FollowUnit = held.Focus;
_current.SubjectId = EventFeedUtil.SafeId(held.Focus);
}
if (held.Related != null && held.Related.isAlive())
{
_current.RelatedUnit = held.Related;
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
}
// Outbreak stickies are groups only - never "Outbreak - X (0)" / solo (1).
int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
if (carriers < 2)
{
_current.Label = "";
_current.AssetId = "live_outbreak";
return _current.HasFollowUnit;
}
string label = EventReason.StatusOutbreak(held);
if (string.IsNullOrEmpty(label))
{
label = StickyScoreboard.FormatReason(_current, _current.FollowUnit, _current.RelatedUnit);
}
if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(_current.Label))
{
label = _current.Label;
}
bool followChanged = held.Focus != null && _current.FollowUnit != held.Focus;
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
_current.Label = label ?? "";
_current.AssetId = "live_outbreak";
try
{
if (_current.FollowUnit != null)
{
_current.Position = _current.FollowUnit.current_position;
}
}
catch
{
// keep
}
bool needWatch = forceWatch
|| followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| (_current.FollowUnit != null && MoveCamera._focus_unit != _current.FollowUnit);
if (needWatch && _current.HasFollowUnit)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return _current.HasFollowUnit;
}
private static bool HasStickyCombatSides(InterestCandidate scene)
{
return StickyScoreboard.HasOpposingSides(scene);
}
private static void ResolveCombatPairActors(
InterestCandidate scene,
out Actor owner,
out Actor partner)
{
owner = null;
partner = null;
if (scene == null)
{
return;
}
if (scene.PairOwnerId != 0)
{
owner = LiveEnsemble.FindTrackedActor(scene.PairOwnerId);
}
if (owner == null || !owner.isAlive())
{
owner = scene.FollowUnit != null && scene.FollowUnit.isAlive()
? scene.FollowUnit
: null;
}
if (scene.PairPartnerId != 0)
{
partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId);
}
if (partner == null || !partner.isAlive())
{
partner = scene.RelatedUnit != null && scene.RelatedUnit.isAlive()
? scene.RelatedUnit
: null;
}
if (partner != null && owner != null && partner == owner)
{
partner = null;
}
}
/// <summary>
/// True when the scene already owns a collective multi battle (post-escalate),
/// not a NamedPair duel that ambient units merely wandered near.
/// </summary>
private static bool HasStickyCollectiveMulti(InterestCandidate scene)
{
if (scene == null || !HasStickyCombatSides(scene))
{
return false;
}
if (scene.CombatSideFrame != EnsembleFrame.SpeciesVsSpecies
&& scene.CombatSideFrame != EnsembleFrame.KingdomVsKingdom)
{
return false;
}
return scene.CombatPeakParticipants >= 3
|| string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Living 1v1 ownership: Duel tips and forced pair sessions stay NamedPair until a
/// real multi escalate (sticky collective camps), not ambient nearby melees.
/// </summary>
private static bool IsNamedPairCombatOwnership(InterestCandidate scene)
{
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
ResolveCombatPairActors(scene, out Actor owner, out Actor partner);
bool pairIdsLive = owner != null && owner.isAlive()
&& partner != null && partner.isAlive();
if (HasStickyCollectiveMulti(scene))
{
string label = scene.Label ?? "";
// Explicit Duel tip wins over ambient sticky pollution; collective tips own the scene.
return label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
}
if (scene.HasCombatPairLock && pairIdsLive)
{
return true;
}
string tip = scene.Label ?? "";
bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
bool thinFight = tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0;
bool hasPartner = partner != null || (scene.RelatedUnit != null && scene.RelatedUnit.isAlive());
if (duelLabeled)
{
return true;
}
if (scene.ForceActive && hasPartner)
{
return true;
}
if (hasPartner
&& owner != null
&& owner.isAlive()
&& (thinFight || scene.ParticipantCount <= 2 || scene.CombatPeakParticipants <= 2))
{
return true;
}
return false;
}
/// <summary>
/// Pick a living sticky-roster fighter when the current follow left the scrap.
/// </summary>
private static bool TryRetargetCombatFollow(
InterestCandidate scene,
out Actor fighter,
out Actor foe)
{
fighter = null;
foe = null;
if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
{
return false;
}
LiveSceneStickyState sticky = scene.Sticky;
Actor pickA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: true);
Actor pickB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: true);
bool aFighting = LiveEnsemble.IsCombatParticipant(pickA);
bool bFighting = LiveEnsemble.IsCombatParticipant(pickB);
if (!aFighting && !bFighting)
{
return false;
}
if (aFighting && bFighting)
{
float wA = LiveEnsemble.FocusWeight(pickA, scene.ParticipantIds, 0f);
float wB = LiveEnsemble.FocusWeight(pickB, scene.ParticipantIds, 0f);
if (wB > wA)
{
fighter = pickB;
foe = pickA;
}
else
{
fighter = pickA;
foe = pickB;
}
}
else if (aFighting)
{
fighter = pickA;
foe = pickB;
}
else
{
fighter = pickB;
foe = pickA;
}
return fighter != null && fighter.isAlive();
}
/// <summary>
/// Allow leaving NamedPair only when a real sided multi owns the scrap
/// (species/kingdom camps with 3+ fighters), never from ambient radius noise.
/// </summary>
private static bool ShouldEscalateNamedPair(
InterestCandidate scene,
LiveEnsemble ensemble,
int fighters)
{
if (scene == null)
{
return false;
}
string tip = scene.Label ?? "";
bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
// Already-framed collective multi may leave NamedPair, unless a Duel tip is still
// holding ownership over ambient sticky pollution (needs a live sided ensemble).
if (HasStickyCollectiveMulti(scene) && !duelLabeled)
{
return true;
}
if (fighters < 3 || ensemble == null || !LiveEnsemble.HasOpposingCollectiveSides(ensemble))
{
return false;
}
// Natural growth: both pair members still fighting inside a sided multi cluster.
ResolveCombatPairActors(scene, out Actor owner, out Actor partner);
Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit;
Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit;
if (focus == null || !focus.isAlive() || related == null || !related.isAlive())
{
return false;
}
return LiveEnsemble.IsCombatParticipant(focus)
&& LiveEnsemble.IsCombatParticipant(related);
}
private static void ApplyNamedPairHold(
InterestCandidate scene,
Actor ownedFocus,
Actor ownedRelated,
ref Actor best,
ref Actor foe,
ref int fighters,
ref string label)
{
if (scene == null || ownedFocus == null || !ownedFocus.isAlive())
{
return;
}
// Drop ambient species/kingdom sticky that leaked in during cluster Refresh.
if (HasStickyCombatSides(scene)
&& (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies
|| scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom))
{
scene.ClearCombatSticky();
}
best = ownedFocus;
foe = ResolvePairPartner(ownedFocus, ownedRelated, foe);
fighters = Mathf.Max(2, Math.Min(fighters, 2));
scene.ParticipantCount = Mathf.Max(2, scene.ParticipantCount);
if (string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
&& scene.CombatPeakParticipants <= 2)
{
scene.AssetId = "live_combat";
}
var pairHold = new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Scale = EnsembleScale.Pair,
Frame = EnsembleFrame.NamedPair,
ParticipantCount = 2,
Focus = best,
Related = foe
};
label = EventReason.Combat(pairHold);
if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(scene.Label))
{
label = scene.Label;
}
}
private static Actor ResolvePairPartner(Actor focus, Actor ownedRelated, Actor rebuiltFoe)
{
if (ownedRelated != null && ownedRelated.isAlive() && ownedRelated != focus)
{
return ownedRelated;
}
Actor attack = ResolveAttackFoe(focus);
if (attack != null && attack != focus)
{
return attack;
}
if (rebuiltFoe != null && rebuiltFoe.isAlive() && rebuiltFoe != focus)
{
return rebuiltFoe;
}
return null;
}
/// <summary>
/// Hold Duel ownership whenever Follow+Related still form the live pair.
/// Ambient sticky camps do not unlock a living NamedPair.
/// </summary>
private static bool ShouldHoldDuelOwnership(
InterestCandidate scene,
Actor best,
Actor foe,
int fighters,
Actor ownedFocus = null,
Actor ownedRelated = null)
{
if (scene == null)
{
return false;
}
Actor curFocus = ownedFocus != null && ownedFocus.isAlive()
? ownedFocus
: scene.FollowUnit;
Actor curRelated = ownedRelated != null && ownedRelated.isAlive()
? ownedRelated
: scene.RelatedUnit;
if (curFocus == null || !curFocus.isAlive()
|| curRelated == null || !curRelated.isAlive())
{
return false;
}
// Collective multi (including Duel mid-escalate after sticky Refresh) leaves the pair.
if (fighters >= 3 && HasStickyCollectiveMulti(scene))
{
return false;
}
if (HasStickyCollectiveMulti(scene))
{
string tip = scene.Label ?? "";
if (!tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
bool duelLabeled = !string.IsNullOrEmpty(scene.Label)
&& scene.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
bool smallCluster = fighters <= 2;
if (!duelLabeled && !smallCluster && !scene.ForceActive)
{
return false;
}
// Rebuilt focus/foe is the same two actors (any order), or only one of them
// remains visible in the cluster (foe briefly missing).
return IsSameCombatPair(curFocus, curRelated, best, foe);
}
/// <summary>
/// True when <paramref name="best"/>/<paramref name="foe"/> are the same two actors as the
/// current pair (possibly swapped). Used to lock Duel tip ownership.
/// </summary>
private static bool IsSameCombatPair(Actor curFocus, Actor curRelated, Actor best, Actor foe)
{
if (curFocus == null || !curFocus.isAlive() || best == null || !best.isAlive())
{
return false;
}
if (best == curFocus)
{
return foe == null
|| foe == curRelated
|| foe == curFocus
|| (curRelated == null && ResolveAttackFoe(curFocus) == foe);
}
if (best == curRelated)
{
return foe == null || foe == curFocus || foe == curRelated;
}
Actor liveFoe = ResolveAttackFoe(curFocus) ?? curRelated;
return liveFoe != null && (best == liveFoe) && (foe == null || foe == curFocus);
}
private static Actor ResolveAttackFoe(Actor actor)
{
if (actor == null)
{
return null;
}
try
{
if (actor.has_attack_target
&& actor.attack_target != null
&& actor.attack_target.isAlive()
&& actor.attack_target.isActor())
{
Actor foe = actor.attack_target.a;
return foe != null && foe.isAlive() ? foe : null;
}
}
catch
{
// ignore
}
return null;
}
/// <summary>
/// Rewatch tip only when tier or collective sides change - not on (6)↔(5) count flaps.
/// </summary>
private static bool CombatLabelNeedsRewatch(string oldLabel, string newLabel)
{
if (string.IsNullOrEmpty(oldLabel))
{
return true;
}
if (string.IsNullOrEmpty(newLabel))
{
return true;
}
string a = StripCombatLabelCounts(oldLabel);
string b = StripCombatLabelCounts(newLabel);
return !a.Equals(b, StringComparison.OrdinalIgnoreCase);
}
private static string StripCombatLabelCounts(string label)
{
if (string.IsNullOrEmpty(label))
{
return "";
}
var sb = new System.Text.StringBuilder(label.Length);
for (int i = 0; i < label.Length; i++)
{
char c = label[i];
if (c == '(')
{
int close = label.IndexOf(')', i);
if (close > i)
{
i = close;
continue;
}
}
sb.Append(c);
}
return sb.ToString().Trim();
}
private static void ApplyEnsembleFields(InterestCandidate scene, LiveEnsemble ensemble)
{
if (scene == null || ensemble == null)
{
return;
}
scene.ParticipantCount = Math.Max(0, ensemble.ParticipantCount);
if (scene.ParticipantCount > scene.CombatPeakParticipants)
{
scene.CombatPeakParticipants = scene.ParticipantCount;
}
scene.NotableParticipantCount = ensemble.NotableParticipantCount;
WorldActivityScanner.StampParticipantIds(scene, ensemble);
if (ensemble.Scale >= EnsembleScale.Skirmish)
{
scene.AssetId = "live_battle";
}
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
&& ensemble.SideA != null
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
{
scene.KingdomKey = ensemble.SideA.Key;
}
if (ensemble.Frame == EnsembleFrame.SpeciesVsSpecies
&& ensemble.SideA != null
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
{
scene.SpeciesId = ensemble.SideA.Key;
}
StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble);
}
/// <summary>
/// If the active combat scene already covers this battle anchor, refresh it in place
/// instead of registering a second live_battle candidate.
/// </summary>
public static bool TryAbsorbCombatBattle(InterestEvent battle)
{
if (battle == null
|| _current == null
|| _current.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
Vector3 anchor = battle.Position;
Vector3 cur = _current.Position;
try
{
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
{
cur = _current.FollowUnit.current_position;
}
}
catch
{
// keep
}
float dx = anchor.x - cur.x;
float dy = anchor.y - cur.y;
float mergeR = 16f;
bool near = dx * dx + dy * dy <= mergeR * mergeR;
bool shared = false;
if (!near && battle.FollowUnit != null)
{
long id = EventFeedUtil.SafeId(battle.FollowUnit);
shared = id != 0 && _current.ParticipantIds != null && _current.ParticipantIds.Contains(id);
}
if (!near && !shared)
{
return false;
}
if (battle.ParticipantCount > _current.ParticipantCount)
{
_current.ParticipantCount = battle.ParticipantCount;
}
if (battle.NotableParticipantCount > _current.NotableParticipantCount)
{
_current.NotableParticipantCount = battle.NotableParticipantCount;
}
ApplyCombatEnsembleToCurrent(forceWatch: true);
return true;
}
/// <summary>Harness: apply a synthetic combat ensemble onto the current scene.</summary>
public static bool HarnessApplyCombatEnsemble(LiveEnsemble ensemble)
{
if (_current == null
|| ensemble == null
|| !ensemble.HasFocus
|| _current.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
int authoredN = Mathf.Max(
ensemble.ParticipantCount,
(ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0));
ApplyEnsembleFields(_current, ensemble);
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
StickyScoreboard.Refresh(
_current,
ensemble,
ensemble.Focus.current_position,
14f);
// Harness escalate authors side counts before units fully enter combat scans.
if (authoredN > _current.ParticipantCount)
{
_current.ParticipantCount = authoredN;
}
if (ensemble.SideA != null && ensemble.SideA.Count > _current.CombatSideACount)
{
_current.CombatSideACount = ensemble.SideA.Count;
}
if (ensemble.SideB != null && ensemble.SideB.Count > _current.CombatSideBCount)
{
_current.CombatSideBCount = ensemble.SideB.Count;
}
if (authoredN > _current.CombatPeakParticipants)
{
_current.CombatPeakParticipants = authoredN;
}
// Leaving NamedPair: clear durable pair lock so maintain may keep collective framing.
if (authoredN >= 3 || LiveEnsemble.HasOpposingCollectiveSides(ensemble))
{
_current.PairOwnerId = 0;
_current.PairPartnerId = 0;
}
_current.FollowUnit = ensemble.Focus;
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
_current.RelatedUnit = ensemble.Related;
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
_current.Label = EventReason.Combat(ensemble);
try
{
_current.Position = ensemble.Focus.current_position;
}
catch
{
// keep
}
CameraDirector.Watch(_current.ToInterestEvent());
return true;
}
private static void ClearStaleCombatLabel(float now)
{
if (_current == null || string.IsNullOrEmpty(_current.Label))
{
return;
}
string label = _current.Label;
if (label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("duel", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("skirmish", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("clash", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("battle", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("mass -", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("leading a fight", StringComparison.OrdinalIgnoreCase) < 0)
{
return;
}
_current.Label = "";
_current.ClearCombatSticky();
_lastCombatFocusAt = now;
CameraDirector.Watch(_current.ToInterestEvent());
}
/// <summary>Harness: force one combat focus maintenance pass.</summary>
public static void HarnessMaintainCombatFocus()
{
MaintainCombatFocus(Time.unscaledTime, force: true);
}
/// <summary>Harness: force one war-front maintenance pass.</summary>
public static void HarnessMaintainWarFront()
{
MaintainWarFront(Time.unscaledTime, force: true);
}
/// <summary>Harness: force one plot-cell maintenance pass.</summary>
public static void HarnessMaintainPlotCell()
{
MaintainPlotCell(Time.unscaledTime, force: true);
}
/// <summary>Harness: force one family-pack maintenance pass.</summary>
public static void HarnessMaintainFamilyPack()
{
MaintainFamilyPack(Time.unscaledTime, force: true);
}
/// <summary>Harness: force one status-outbreak maintenance pass.</summary>
public static void HarnessMaintainStatusOutbreak()
{
MaintainStatusOutbreak(Time.unscaledTime, force: true);
}
/// <summary>Harness: apply a synthetic war ensemble onto the current WarFront scene.</summary>
public static bool HarnessApplyWarEnsemble(LiveEnsemble ensemble)
{
if (_current == null
|| ensemble == null
|| !ensemble.HasFocus
|| _current.Completion != InterestCompletionKind.WarFront)
{
return false;
}
ensemble.Kind = EnsembleKind.WarFront;
ensemble.Frame = EnsembleFrame.KingdomVsKingdom;
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
StickyScoreboard.Refresh(
_current,
ensemble,
ensemble.Focus.current_position,
StickyScoreboard.WarTheaterRadius);
_current.FollowUnit = ensemble.Focus;
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
_current.RelatedUnit = ensemble.Related;
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
_current.Label = EventReason.WarFront(ensemble);
_current.AssetId = "live_war";
_current.ParticipantCount = ensemble.ParticipantCount;
try
{
_current.Position = ensemble.Focus.current_position;
}
catch
{
// keep
}
CameraDirector.Watch(_current.ToInterestEvent());
return true;
}
/// <summary>Harness: apply a synthetic family ensemble onto the current FamilyPack scene.</summary>
public static bool HarnessApplyFamilyEnsemble(LiveEnsemble ensemble)
{
if (_current == null
|| ensemble == null
|| !ensemble.HasFocus
|| _current.Completion != InterestCompletionKind.FamilyPack)
{
return false;
}
ensemble.Kind = EnsembleKind.FamilyPack;
ensemble.Frame = EnsembleFrame.SpeciesVsSpecies;
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
if (ensemble.SideA != null)
{
_current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count);
if (!string.IsNullOrEmpty(ensemble.SideA.Key))
{
_current.Sticky.SideAKey = ensemble.SideA.Key;
}
if (!string.IsNullOrEmpty(ensemble.SideA.Display)
&& !LiveEnsemble.IsFamilyPackSideKey(ensemble.SideA.Display))
{
_current.Sticky.SideADisplay = ensemble.SideA.Display;
}
ensemble.SideA.Count = _current.Sticky.SideACount;
ensemble.SideA.Display = _current.Sticky.SideADisplay;
}
_current.Sticky.SideBKey = "";
_current.Sticky.SideBDisplay = "";
_current.Sticky.SideBCount = 0;
_current.Sticky.PeakParticipants = Math.Max(
_current.Sticky.PeakParticipants,
_current.Sticky.SideACount);
_current.FollowUnit = ensemble.Focus;
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
_current.RelatedUnit = ensemble.Related;
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
if (_current.Sticky != null && ensemble.ParticipantIds != null)
{
_current.Sticky.SideAIds.Clear();
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
{
long id = ensemble.ParticipantIds[i];
if (id != 0)
{
_current.Sticky.SideAIds.Add(id);
}
}
if (ensemble.SideA != null)
{
_current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count);
}
}
_current.Label = EventReason.FamilyPack(ensemble);
if (string.IsNullOrEmpty(_current.Label))
{
_current.Label = StickyScoreboard.FormatReason(_current, ensemble.Focus, ensemble.Related);
}
_current.AssetId = "live_family";
_current.ParticipantCount = ensemble.ParticipantCount;
try
{
_current.Position = ensemble.Focus.current_position;
}
catch
{
// keep
}
CameraDirector.Watch(_current.ToInterestEvent());
return true;
}
/// <summary>Harness: apply a synthetic outbreak ensemble onto the current StatusOutbreak scene.</summary>
public static bool HarnessApplyStatusOutbreakEnsemble(LiveEnsemble ensemble)
{
if (_current == null
|| ensemble == null
|| !ensemble.HasFocus
|| _current.Completion != InterestCompletionKind.StatusOutbreak)
{
return false;
}
ensemble.Kind = EnsembleKind.StatusOutbreak;
ensemble.Frame = EnsembleFrame.SpeciesVsSpecies;
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
if (ensemble.SideA != null)
{
_current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count);
if (!string.IsNullOrEmpty(ensemble.SideA.Key))
{
_current.Sticky.SideAKey = ensemble.SideA.Key;
if (LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key))
{
_current.StatusId = ensemble.SideA.Key.Substring("status:".Length);
}
}
if (!string.IsNullOrEmpty(ensemble.SideA.Display)
&& !LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Display))
{
_current.Sticky.SideADisplay = ensemble.SideA.Display;
}
ensemble.SideA.Count = _current.Sticky.SideACount;
ensemble.SideA.Display = _current.Sticky.SideADisplay;
}
_current.Sticky.SideBKey = "";
_current.Sticky.SideBDisplay = "";
_current.Sticky.SideBCount = 0;
_current.Sticky.PeakParticipants = Math.Max(
_current.Sticky.PeakParticipants,
_current.Sticky.SideACount);
_current.FollowUnit = ensemble.Focus;
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
_current.RelatedUnit = ensemble.Related;
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
_current.Label = EventReason.StatusOutbreak(ensemble);
if (string.IsNullOrEmpty(_current.Label))
{
_current.Label = StickyScoreboard.FormatReason(_current, ensemble.Focus, ensemble.Related);
}
_current.AssetId = "live_outbreak";
_current.ParticipantCount = ensemble.ParticipantCount;
try
{
_current.Position = ensemble.Focus.current_position;
}
catch
{
// keep
}
CameraDirector.Watch(_current.ToInterestEvent());
return true;
}
/// <summary>Harness: apply a synthetic plot ensemble onto the current PlotActive scene.</summary>
public static bool HarnessApplyPlotEnsemble(LiveEnsemble ensemble)
{
if (_current == null
|| ensemble == null
|| !ensemble.HasFocus
|| _current.Completion != InterestCompletionKind.PlotActive)
{
return false;
}
ensemble.Kind = EnsembleKind.PlotCell;
ensemble.Frame = EnsembleFrame.KingdomVsKingdom;
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
// Harness drives explicit counts; seed roster then pin counts for tip asserts.
if (ensemble.SideA != null)
{
_current.Sticky.SideACount = Math.Max(0, ensemble.SideA.Count);
_current.Sticky.SideADisplay = "Plotters";
if (!string.IsNullOrEmpty(ensemble.SideA.Key))
{
_current.Sticky.SideAKey = ensemble.SideA.Key;
}
}
if (ensemble.SideB != null)
{
_current.Sticky.SideBCount = Math.Max(0, ensemble.SideB.Count);
if (!string.IsNullOrEmpty(ensemble.SideB.Key))
{
_current.Sticky.SideBKey = ensemble.SideB.Key;
_current.Sticky.SideBDisplay = string.IsNullOrEmpty(ensemble.SideB.Display)
? LiveEnsemble.KingdomDisplay(ensemble.SideB.Key)
: ensemble.SideB.Display;
_current.Sticky.SideBKingdom = _current.Sticky.SideBDisplay;
}
}
if (ensemble.SideA != null)
{
ensemble.SideA.Display = "Plotters";
ensemble.SideA.Count = _current.Sticky.SideACount;
}
if (ensemble.SideB != null)
{
ensemble.SideB.Count = _current.Sticky.SideBCount;
ensemble.SideB.Display = _current.Sticky.SideBDisplay;
}
_current.Sticky.PeakParticipants = Math.Max(
_current.Sticky.PeakParticipants,
_current.Sticky.TotalCount);
_current.FollowUnit = ensemble.Focus;
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
_current.RelatedUnit = ensemble.Related;
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
// Groups only - leave Label empty below 2 plotters (no unit-plot fallback on sticky).
_current.Label = EventReason.PlotCell(ensemble) ?? "";
_current.AssetId = "live_plot";
_current.ParticipantCount = ensemble.ParticipantCount;
try
{
_current.Position = ensemble.Focus.current_position;
}
catch
{
// keep
}
CameraDirector.Watch(_current.ToInterestEvent());
return true;
}
/// <summary>
/// Keep a living focus unit whenever Idle Spectator is on.
/// Covers death mid-combat and quiet_grace windows where vanilla cleared follow.
/// </summary>
private static void MaintainCameraFocus(float now)
{
if (!SpectatorMode.Active)
{
return;
}
if (HasLivingCameraFocus())
{
return;
}
if (_current != null)
{
if (_current.Completion == InterestCompletionKind.CombatActive)
{
MaintainCombatFocus(now, force: true);
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.Completion == InterestCompletionKind.WarFront)
{
MaintainWarFront(now, force: true);
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.Completion == InterestCompletionKind.PlotActive)
{
MaintainPlotCell(now, force: true);
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.Completion == InterestCompletionKind.FamilyPack)
{
MaintainFamilyPack(now, force: true);
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.Completion == InterestCompletionKind.StatusOutbreak)
{
MaintainStatusOutbreak(now, force: true);
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.HasFollowUnit)
{
CameraDirector.Watch(_current.ToInterestEvent());
if (HasLivingCameraFocus())
{
return;
}
}
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
// Non-combat ownership handoff; sticky scenes already tried picker above.
if (_current.Completion != InterestCompletionKind.CombatActive
&& _current.Completion != InterestCompletionKind.WarFront
&& _current.Completion != InterestCompletionKind.PlotActive
&& _current.Completion != InterestCompletionKind.FamilyPack
&& _current.Completion != InterestCompletionKind.StatusOutbreak)
{
_current.FollowUnit = _current.RelatedUnit;
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
CameraDirector.Watch(_current.ToInterestEvent());
if (HasLivingCameraFocus())
{
return;
}
}
}
// Orphan scene (no living follow/related) - end and refill.
EndCurrent(now, reason: "follow_lost");
return;
}
EnsureIdleFocus(now);
}
private static bool HasLivingCameraFocus()
{
return MoveCamera.hasFocusUnit()
&& MoveCamera._focus_unit != null
&& MoveCamera._focus_unit.isAlive();
}
private static void EndCurrent(float now, string reason)
{
if (_current != null)
{
InterestRegistry.Remove(_current.Key);
LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label);
}
// Resume interrupted Epic-preempted scene if still valid.
if (_interrupted != null
&& _interrupted.HasValidPosition
&& InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now))
{
InterestCandidate resume = _interrupted;
_interrupted = null;
SwitchTo(resume, now, resumableInterrupt: false);
return;
}
_interrupted = null;
_current = null;
_inactiveSince = -999f;
EnsureIdleFocus(now);
}
/// <summary>
/// After scene end, keep a living focus unit so idle never flashes the power bar.
/// </summary>
private static void EnsureIdleFocus(float now)
{
if (!SpectatorMode.Active)
{
return;
}
if (HasLivingCameraFocus())
{
return;
}
TryCharacterFill(force: true);
if (HasLivingCameraFocus())
{
return;
}
Actor any = EventFeedUtil.AnyAliveUnit();
if (any != null)
{
CameraDirector.FocusUnit(any);
}
_ = now;
}
private static InterestCandidate SelectNext(float now)
{
InterestRegistry.CopyPending(PendingScratch);
if (PendingScratch.Count == 0)
{
return null;
}
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
for (int i = PendingScratch.Count - 1; i >= 0; i--)
{
InterestCandidate c = PendingScratch[i];
if (c == null || !CanSwitchTo(c, onCurrent, sinceSwitch))
{
PendingScratch.RemoveAt(i);
continue;
}
// Drop stale / not-worth-watching / unpresentable shots from the pending set.
// Harness synthetic labels may be unpresentable (dossier blanks); keep them for tip tests.
bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase);
bool presentable = harness || EventPresentability.WouldShow(c);
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|| IsStaleFreshLifeMoment(c, now)
|| !presentable)
{
if (!presentable)
{
InterestDropLog.Record("unpresentable", c.Label ?? c.Key);
}
InterestRegistry.Remove(c.Key);
PendingScratch.RemoveAt(i);
}
}
if (PendingScratch.Count == 0)
{
return null;
}
InterestScoring.EnrichTopK(PendingScratch);
return InterestVariety.Pick(PendingScratch, _current);
}
private static bool IsStaleFreshLifeMoment(InterestCandidate c, float now)
{
if (c == null || string.IsNullOrEmpty(c.HappinessEffectId))
{
return false;
}
string id = c.HappinessEffectId;
bool hatch = string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase);
bool spawn = string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase);
if (!hatch && !spawn)
{
return false;
}
// Queued too long - miss the beat.
if (now - c.CreatedAt > 5.5f)
{
InterestDropLog.Record("stale", c.HappinessEffectId + " queue");
return true;
}
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{
InterestDropLog.Record("stale", c.HappinessEffectId + " dead");
return true;
}
// Hatch: egg-loss / happiness may land after the unit is already past baby form.
// Only drop on age for spawn (just_born), not hatch.
if (spawn)
{
try
{
if (!unit.isBaby() && unit.getAge() > 1)
{
InterestDropLog.Record("stale", "just_born grown");
return true;
}
}
catch
{
// ignore age API mismatches
}
}
return false;
}
/// <summary>
/// True when a drained New species tip would be allowed to take the camera.
/// </summary>
public static bool WouldAcceptCuriosity()
{
if (_current == null)
{
return true;
}
float onCurrent = Time.unscaledTime - _currentStartedAt;
float sinceSwitch = Time.unscaledTime - _lastSwitchAt;
return CanSwitchTo(
new InterestCandidate
{
Key = "probe:curiosity",
EventStrength = 40f,
LeadKind = InterestLeadKind.CharacterLed,
Completion = InterestCompletionKind.CharacterVignette,
TotalScore = 45f
},
onCurrent,
sinceSwitch);
}
public static bool SimulateManualInputExit(bool honorGrace = false)
{
if (!SpectatorMode.Active)
{
return false;
}
MoveCamera.camera_drag_run = true;
bool detected = PlayerTookManualControl();
if (!detected)
{
MoveCamera.camera_drag_run = false;
return false;
}
if (honorGrace && Time.unscaledTime - _enabledAt < _inputExitGrace)
{
MoveCamera.camera_drag_run = false;
return false;
}
ExitFromManualInput();
MoveCamera.camera_drag_run = false;
return !SpectatorMode.Active;
}
private static void ExitFromManualInput()
{
SpectatorMode.SetActive(false, quiet: true);
WatchCaption.ShowStatusBanner("Paused (manual input)");
LogService.LogInfo("[IdleSpectator] Spectator paused (manual input)");
}
private static bool PlayerTookManualControl()
{
if (MoveCamera.camera_drag_run)
{
return true;
}
if (Mathf.Abs(Input.mouseScrollDelta.y) > 0.01f)
{
return true;
}
if (HotkeyLibrary.left != null
&& (HotkeyLibrary.left.isHolding()
|| HotkeyLibrary.right.isHolding()
|| HotkeyLibrary.up.isHolding()
|| HotkeyLibrary.down.isHolding()))
{
return true;
}
if (HotkeyLibrary.zoom_in != null
&& (HotkeyLibrary.zoom_in.isHolding() || HotkeyLibrary.zoom_out.isHolding()))
{
return true;
}
if (!World.world.isOverUI()
&& !WatchCaption.IsPointerOverInteractive()
&& !ChronicleHud.IsPointerOverPanel()
&& (InputHelpers.GetMouseButtonDown(0) || InputHelpers.GetMouseButtonDown(1)))
{
return true;
}
return false;
}
private static float MinDwellFor(InterestCandidate current)
{
ScoringWeights w = InterestScoringConfig.W;
float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds;
if (current == null)
{
return _minDwell < MinDwellSeconds * 0.5f ? _minDwell : floor;
}
// Ambient / Character fill: no event floor - real events may take the camera anytime.
if (IsAmbientShot(current))
{
if (_minDwell < MinDwellSeconds * 0.5f)
{
return InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell;
}
return w.dwellFill > 0f ? w.dwellFill : 6f;
}
float dwell = InterestScoring.IsHotScore(current.TotalScore)
? w.dwellHot
: w.dwellNormal;
if (_minDwell < MinDwellSeconds * 0.5f)
{
// Harness fast timing: keep compressed dwells.
dwell = _minDwell;
return current.MinWatch > 0f ? Mathf.Max(current.MinWatch, dwell) : dwell;
}
// Live EventLed: never peer-rotate / soft-cut before the camera floor.
// Score-margin interrupts bypass this via CanSwitchTo.
float minWatch = current.MinWatch > 0f ? current.MinWatch : 0f;
return Mathf.Max(floor, dwell, minWatch);
}
/// <summary>Character fill / low-score stroll - events should cut in freely.</summary>
private static bool IsAmbientShot(InterestCandidate c)
{
if (c == null)
{
return true;
}
if (c.LeadKind == InterestLeadKind.CharacterLed
|| c.Completion == InterestCompletionKind.CharacterVignette)
{
return true;
}
// Sticky event holds are never ambient even if score is temporarily fill-band.
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.StatusPhase
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| c.Completion == InterestCompletionKind.HappinessGrief
|| c.Completion == InterestCompletionKind.ActivityActive)
{
return false;
}
return InterestScoring.IsFillScore(c.TotalScore);
}
private static float MaxWatchFor(InterestCandidate current)
{
if (current == null)
{
return InterestScoringConfig.W.maxWatchMoment;
}
ScoringWeights w = InterestScoringConfig.W;
float classCap = w.maxWatchMoment;
switch (current.Completion)
{
case InterestCompletionKind.CombatActive:
case InterestCompletionKind.WarFront:
case InterestCompletionKind.PlotActive:
classCap = w.maxWatchCombat;
break;
case InterestCompletionKind.FamilyPack:
// Soft cluster - moment-class cap so packs cannot outlast unit events.
classCap = w.maxWatchMoment;
break;
case InterestCompletionKind.StatusPhase:
case InterestCompletionKind.StatusOutbreak:
classCap = w.maxWatchStatus;
break;
case InterestCompletionKind.HappinessGrief:
classCap = w.maxWatchGrief;
break;
case InterestCompletionKind.FixedDwell:
case InterestCompletionKind.Manual:
classCap = current.EventStrength >= w.hotScoreMin
? w.maxWatchEpic
: w.maxWatchMoment;
break;
default:
if (current.EventStrength >= w.hotScoreMin)
{
classCap = w.maxWatchEpic;
}
break;
}
float cap = classCap;
if (current.MaxWatch > 0f)
{
// Sticky live holds use the class MaxWatch (e.g. combat 60s) - do not let a
// short candidate MaxWatch max_cap the fight while it is still active.
if (current.Completion == InterestCompletionKind.CombatActive
|| current.Completion == InterestCompletionKind.WarFront
|| current.Completion == InterestCompletionKind.PlotActive
|| current.Completion == InterestCompletionKind.StatusPhase
|| current.Completion == InterestCompletionKind.StatusOutbreak
|| current.Completion == InterestCompletionKind.HappinessGrief
|| current.Completion == InterestCompletionKind.ActivityActive)
{
cap = Mathf.Max(current.MaxWatch, classCap);
}
else if (current.Completion == InterestCompletionKind.FamilyPack)
{
// Soft pack: honor the short candidate MaxWatch (do not stretch to combat caps).
cap = Mathf.Min(current.MaxWatch, classCap);
}
else
{
cap = Mathf.Min(current.MaxWatch, classCap);
}
}
// Moment / FixedDwell shots must last at least the camera floor unless interrupted.
if (current.Completion == InterestCompletionKind.FixedDwell
|| current.Completion == InterestCompletionKind.Manual)
{
float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds;
cap = Mathf.Max(cap, floor);
}
// Ambient fill: no minCameraDwell floor - short vignettes are fine.
if (IsAmbientShot(current))
{
float fillCap = w.dwellFill > 0f ? w.dwellFill * 2f : 20f;
if (current.MaxWatch > 0f)
{
fillCap = Mathf.Min(fillCap, Mathf.Max(current.MaxWatch, 8f));
}
cap = fillCap;
}
// Harness fast timing compresses caps.
if (_minDwell < MinDwellSeconds * 0.5f)
{
cap = Mathf.Min(cap, 8f);
}
return cap;
}
/// <summary>Hold while live and under MaxWatch; quiet grace still counts as briefly protected.</summary>
private static bool SessionProtected(float now, float onCurrent)
{
if (_current == null)
{
return false;
}
if (onCurrent >= MaxWatchFor(_current))
{
return false;
}
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
if (!active)
{
// Ambient: no minCameraDwell hold after the vignette goes cold.
if (!IsAmbientShot(_current) && onCurrent < MinDwellFor(_current))
{
return true;
}
if (_inactiveSince >= 0f && now - _inactiveSince >= _quietGrace)
{
return false;
}
// Still in quiet grace - treat as protected briefly.
return _inactiveSince < 0f || now - _inactiveSince < _quietGrace;
}
return true;
}
private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch)
{
if (candidate == null)
{
return false;
}
// Same FixedDwell beat already watched on this subject - never cut back in.
if (InterestVariety.IsBeatCooled(candidate))
{
InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key);
return false;
}
if (_current == null)
{
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
}
float now = Time.unscaledTime;
bool inGrace = InQuietGrace;
if (inGrace)
{
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
// Prevents family-join / love status from stealing a fight between swings.
if (_current != null
&& (_current.Completion == InterestCompletionKind.CombatActive
|| _current.Completion == InterestCompletionKind.StatusPhase
|| _current.Completion == InterestCompletionKind.HappinessGrief
|| IsStickyStoryScene(_current)))
{
float graceCur = _current.TotalScore;
float graceNext = candidate.TotalScore;
float onCurrentGrace = now - _currentStartedAt;
float graceMargin = Mathf.Max(
InterestScoringConfig.W.cutInMargin,
InterestScoringConfig.W.stickyCutInMargin > 0f
? InterestScoringConfig.W.stickyCutInMargin
: 50f);
if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate))
{
float valveMargin = InterestScoringConfig.W.stickyVarietyValveMargin > 0f
? InterestScoringConfig.W.stickyVarietyValveMargin
: 20f;
graceMargin = Mathf.Min(graceMargin, valveMargin);
if (graceNext >= graceCur - InterestScoringConfig.W.rotateSlack
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
{
return true;
}
}
return graceNext >= graceCur + graceMargin
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false))
{
return false;
}
bool protectedScene = SessionProtected(now, onCurrent);
ScoringWeights w = InterestScoringConfig.W;
float curScore = _current.TotalScore;
float nextScore = candidate.TotalScore;
bool nextFill = InterestScoring.IsFillScore(nextScore);
bool curAmbient = IsAmbientShot(_current);
bool curFill = curAmbient || InterestScoring.IsFillScore(curScore);
bool sticky = IsStickyStoryScene(_current);
float cutMargin = sticky
? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f)
: w.cutInMargin;
bool varietyValve = VarietyValveOpen(_current, onCurrent)
&& IsVarietyValveCandidate(candidate);
if (varietyValve)
{
float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f;
cutMargin = Mathf.Min(cutMargin, valveMargin);
}
// Ambient fill: any real event may take the camera immediately (no min dwell).
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
{
return true;
}
// Soft family pack: yield to discrete unit events after a short hold.
if (IsSoftStickyCluster(_current)
&& !IsSoftStickyCluster(candidate)
&& !nextFill
&& !IsAmbientShot(candidate)
&& onCurrent >= SoftClusterYieldSeconds
&& nextScore >= curScore - w.rotateSlack)
{
return true;
}
// Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut.
// Score-margin alone can never clear Mass (~150) with lover (~78); use class gate.
if (varietyValve && !nextFill && !IsAmbientShot(candidate))
{
bool hotEnough = InterestScoring.IsHotScore(nextScore)
|| nextScore >= curScore - w.rotateSlack;
bool storyEnough = nextScore >= w.noticeScoreMin
|| candidate.EventStrength >= w.noticeScoreMin;
if (hotEnough || storyEnough)
{
InterestDropLog.Record(
"variety_valve",
$"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}");
return true;
}
}
// Same-arc refresh (combat/hatch/grief keys) may replace without full margin.
if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack)
{
return true;
}
// Instant score-margin cut - no settle grace, no MinDwell block.
// Soft clusters use the normal cut-in margin (not stickyCutInMargin).
float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin;
if (nextScore >= curScore + effectiveMargin)
{
return true;
}
if (sticky && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
{
InterestDropLog.Record(
"below_margin",
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
+ (varietyValve ? " valve" : ""));
}
// Fill never cuts a protected non-fill session without margin (already failed above).
if (nextFill && !curFill && protectedScene)
{
return false;
}
float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds;
if (nextFill && curFill && onCurrent < fillRotate)
{
return false;
}
// Protected / sticky EventLed hold: only margin or same-arc (handled above).
if ((protectedScene || sticky) && !curAmbient)
{
return false;
}
// Peer rotate / stronger score after MinDwell when unprotected or on fill.
if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
{
return true;
}
return onCurrent >= MinDwellFor(_current)
&& sinceSwitch >= _switchCooldown
&& nextScore >= curScore - w.rotateSlack;
}
/// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
private const float SoftClusterYieldSeconds = 4f;
private static bool IsSoftStickyCluster(InterestCandidate c) =>
c != null && c.Completion == InterestCompletionKind.FamilyPack;
/// <summary>
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
/// </summary>
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
!_varietyValveUsed && VarietyValveTimeReady(scene, onCurrent);
private static bool VarietyValveTimeReady(InterestCandidate scene, float onCurrent)
{
if (scene == null
|| (scene.Completion != InterestCompletionKind.CombatActive
&& scene.Completion != InterestCompletionKind.WarFront))
{
return false;
}
ScoringWeights w = InterestScoringConfig.W;
float after = w.stickyVarietyValveAfterSeconds > 0f ? w.stickyVarietyValveAfterSeconds : 20f;
if (onCurrent >= after)
{
return true;
}
float maxW = MaxWatchFor(scene);
float frac = w.stickyVarietyValveMaxWatchFrac > 0f ? w.stickyVarietyValveMaxWatchFrac : 0.5f;
return maxW > 0f && onCurrent >= maxW * frac;
}
/// <summary>
/// Peers allowed through the sticky variety valve (class rules, not tip strings).
/// </summary>
private static bool IsVarietyValveCandidate(InterestCandidate c)
{
if (c == null || InterestScoring.IsFillScore(c.TotalScore) || IsAmbientShot(c))
{
return false;
}
// Never treat another fight/war as a "variety" peer - those use sticky margin / same-arc.
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront
|| string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (c.Completion == InterestCompletionKind.PlotActive
|| c.Completion == InterestCompletionKind.StatusOutbreak)
{
return true;
}
if (InterestScoring.IsHotScore(c.TotalScore)
|| c.EventStrength >= InterestScoringConfig.W.hotScoreMin)
{
return true;
}
if (c.EventStrength >= InterestScoringConfig.W.noticeScoreMin
&& c.LeadKind == InterestLeadKind.EventLed)
{
return true;
}
if (IsParkableLifeBeat(c))
{
return true;
}
if (c.Completion == InterestCompletionKind.StatusPhase
&& IsLethalStatusCandidate(c))
{
return true;
}
// New-species discovery Curiosity.
if (!string.IsNullOrEmpty(c.Key)
&& c.Key.StartsWith("species:", StringComparison.OrdinalIgnoreCase))
{
return true;
}
string label = c.Label ?? "";
return label.StartsWith("New species:", StringComparison.OrdinalIgnoreCase);
}
private static bool IsParkableLifeBeat(InterestCandidate c)
{
if (c == null)
{
return false;
}
string asset = c.AssetId ?? "";
string key = c.Key ?? "";
string happiness = c.HappinessEffectId ?? "";
if (asset.Equals("set_lover", StringComparison.OrdinalIgnoreCase)
|| asset.Equals("clear_lover", StringComparison.OrdinalIgnoreCase)
|| asset.Equals("find_lover", StringComparison.OrdinalIgnoreCase)
|| asset.Equals("fallen_in_love", StringComparison.OrdinalIgnoreCase)
|| happiness.Equals("fallen_in_love", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return key.StartsWith("rel:set_lover", StringComparison.OrdinalIgnoreCase)
|| key.StartsWith("rel:clear_lover", StringComparison.OrdinalIgnoreCase)
|| key.StartsWith("rel:find_lover", StringComparison.OrdinalIgnoreCase);
}
private static bool IsLethalStatusCandidate(InterestCandidate c)
{
if (c == null)
{
return false;
}
string id = c.StatusId ?? c.AssetId ?? "";
if (string.IsNullOrEmpty(id))
{
return false;
}
return id.IndexOf("drown", StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("burn", StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("poison", StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("frozen", StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("curse", StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("infect", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool IsStickyStoryScene(InterestCandidate c)
{
if (c == null)
{
return false;
}
// FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster).
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront
|| c.Completion == InterestCompletionKind.PlotActive
|| c.Completion == InterestCompletionKind.StatusPhase
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| c.Completion == InterestCompletionKind.HappinessGrief)
{
return true;
}
// Hatch FixedDwell is a story beat - hold against weak peers.
if (!string.IsNullOrEmpty(c.HappinessEffectId)
&& (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
if (!string.IsNullOrEmpty(c.Key)
&& (c.Key.StartsWith("combat:", StringComparison.Ordinal)
|| c.Key.StartsWith("hatch:", StringComparison.Ordinal)))
{
return true;
}
return false;
}
private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next)
{
if (current == null || next == null)
{
return false;
}
if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
{
return true;
}
// Combat / hatch share key prefixes per subject.
if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key))
{
if (SameKeyPrefix(current.Key, next.Key, "combat:")
|| SameKeyPrefix(current.Key, next.Key, "hatch:"))
{
return true;
}
}
if (current.Completion == InterestCompletionKind.HappinessGrief
&& next.Completion == InterestCompletionKind.HappinessGrief
&& current.SubjectId != 0
&& current.SubjectId == next.SubjectId)
{
return true;
}
return false;
}
private static bool SameKeyPrefix(string a, string b, string prefix)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
{
return false;
}
if (!a.StartsWith(prefix, StringComparison.Ordinal)
|| !b.StartsWith(prefix, StringComparison.Ordinal))
{
return false;
}
return string.Equals(a, b, StringComparison.Ordinal);
}
/// <summary>
/// Still-worth-watching filter: drop stale queued moments; keep live sticky conditions
/// and fresh FixedDwell; during grace, brand-new fires are always eligible.
/// </summary>
private static bool IsWorthWatchingNow(InterestCandidate c, float now, bool selectingDuringGrace)
{
if (c == null || !c.HasValidPosition)
{
return false;
}
if (selectingDuringGrace && c.CreatedAt >= _inactiveSince && _inactiveSince >= 0f)
{
return true;
}
// Purge moments that fired before the current scene started (stale queue).
// Parkable life beats may still wait for the variety valve under combat/war.
if (_current != null && c.CreatedAt < _currentStartedAt - 0.05f)
{
if (IsParkableLifeBeat(c)
&& (_current.Completion == InterestCompletionKind.CombatActive
|| _current.Completion == InterestCompletionKind.WarFront)
&& now - c.CreatedAt < 45f)
{
return true;
}
if (c.Completion == InterestCompletionKind.FixedDwell
|| c.Completion == InterestCompletionKind.Manual)
{
return false;
}
// Sticky: only if still live.
return InterestCompletion.IsActive(c, c.CreatedAt, now);
}
if (IsStaleMomentCandidate(c, now))
{
return false;
}
switch (c.Completion)
{
case InterestCompletionKind.CombatActive:
case InterestCompletionKind.StatusPhase:
case InterestCompletionKind.HappinessGrief:
case InterestCompletionKind.ActivityActive:
return InterestCompletion.IsActive(c, c.CreatedAt, now)
|| now - c.LastSeenAt < 2f;
case InterestCompletionKind.FixedDwell:
case InterestCompletionKind.Manual:
// Fresh moment: age under ~4s or more than half MaxWatch remaining.
float age = now - c.CreatedAt;
if (age < 4f)
{
return true;
}
if (IsParkableLifeBeat(c)
&& _current != null
&& (_current.Completion == InterestCompletionKind.CombatActive
|| _current.Completion == InterestCompletionKind.WarFront)
&& age < 45f)
{
return true;
}
float maxW = c.MaxWatch > 0f ? c.MaxWatch : InterestScoringConfig.W.maxWatchMoment;
return age < maxW * 0.5f;
default:
return true;
}
}
private static bool IsStaleMomentCandidate(InterestCandidate c, float now)
{
if (IsStaleFreshLifeMoment(c, now))
{
return true;
}
if (c.ExpiresAt > 0f && now > c.ExpiresAt)
{
return true;
}
// Queued under an active hold: short TTL for FixedDwell moments.
// Life beats (lover / bond) park longer under sticky combat/war so the variety
// valve can still cut them in instead of soft-dropping forever.
if (_current != null
&& CurrentIsActive
&& (c.Completion == InterestCompletionKind.FixedDwell || c.Completion == InterestCompletionKind.Manual)
&& now - c.CreatedAt > 8f)
{
bool parkLife = IsParkableLifeBeat(c)
&& (_current.Completion == InterestCompletionKind.CombatActive
|| _current.Completion == InterestCompletionKind.WarFront);
if (parkLife)
{
return now - c.CreatedAt > 45f;
}
return true;
}
return false;
}
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
{
if (next == null)
{
return;
}
if (!EventPresentability.WouldShow(next)
&& !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase))
{
InterestDropLog.Record("unpresentable", next.Label ?? next.Key);
InterestRegistry.Remove(next.Key);
return;
}
if (_current != null
&& _current.Resumable
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin)
{
_interrupted = _current.CloneShallow();
}
// Consume the once-per-hold variety valve when leaving sticky combat/war for a peer story.
bool leavingStickyCombat = _current != null
&& (_current.Completion == InterestCompletionKind.CombatActive
|| _current.Completion == InterestCompletionKind.WarFront);
bool nextIsCombatTheater = next.Completion == InterestCompletionKind.CombatActive
|| next.Completion == InterestCompletionKind.WarFront;
if (leavingStickyCombat
&& !nextIsCombatTheater
&& IsVarietyValveCandidate(next)
&& VarietyValveTimeReady(_current, now - _currentStartedAt))
{
_varietyValveUsed = true;
}
if (nextIsCombatTheater && !leavingStickyCombat)
{
_varietyValveUsed = false;
}
_current = next;
_currentStartedAt = now;
_lastSwitchAt = now;
_inactiveSince = -999f;
if (!InterestScoring.IsFillScore(next.TotalScore))
{
_lastInterestingAt = now;
}
InterestRegistry.MarkSelected(next.Key);
InterestVariety.NoteSelection(next);
CameraDirector.Watch(next.ToInterestEvent());
}
private static void TryCharacterFill(bool force)
{
float now = Time.unscaledTime;
_lastAmbientAt = now;
if (!force && _current != null && SessionProtected(now, now - _currentStartedAt)
&& InterestScoring.IsHotScore(_current.TotalScore))
{
return;
}
if (!force && _current != null && InterestScoring.IsFillScore(_current.TotalScore)
&& now - _currentStartedAt < _ambientRotate)
{
return;
}
if (!force && now - _lastSwitchAt < _switchCooldown && _current != null)
{
return;
}
InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget();
if (ambient == null)
{
return;
}
// Character fill never owns an orange reason - empty Label + CharacterLed.
// Force fill score so RegisterDirect does not publish as EventLed.
ambient.Label = "";
float fillCap = InterestScoringConfig.W.fillScoreMax > 0f
? InterestScoringConfig.W.fillScoreMax * 0.5f
: 20f;
if (ambient.Score > fillCap || AgentHarness.Busy)
{
ambient.Score = fillCap;
}
InterestCandidate candidate = InterestFeeds.RegisterDirect(ambient);
if (candidate == null)
{
return;
}
candidate.LeadKind = InterestLeadKind.CharacterLed;
candidate.Category = "CharacterVignette";
candidate.Completion = InterestCompletionKind.CharacterVignette;
// Fill is not an event - empty orange reason; task chip shows live job.
candidate.Label = "";
if (AgentHarness.Busy)
{
candidate.ForceActive = false;
candidate.EventStrength = Mathf.Min(candidate.EventStrength, 25f);
InterestScoring.ScoreCheap(candidate);
}
if (!force
&& _current != null
&& _current.FollowUnit != null
&& candidate.FollowUnit != null
&& _current.FollowUnit == candidate.FollowUnit
&& InterestScoring.IsFillScore(candidate.TotalScore))
{
return;
}
if (!force && !CanSwitchTo(candidate, now - _currentStartedAt, now - _lastSwitchAt))
{
return;
}
SwitchTo(candidate, now, resumableInterrupt: false);
LogService.LogInfo("[IdleSpectator] Ambient: " + candidate.Label);
}
}