using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// Evaluates whether an interest scene is still "live" for protection. public static class InterestCompletion { public static bool IsActive(InterestCandidate candidate, float sessionStartedAt, float now) { if (candidate == null) { return false; } if (candidate.ForceActive) { return true; } if (!candidate.HasValidPosition) { return false; } float age = now - sessionStartedAt; if (age >= candidate.MaxWatch) { return false; } switch (candidate.Completion) { case InterestCompletionKind.Manual: return age < candidate.MaxWatch; case InterestCompletionKind.FixedDwell: // Hold the authored MaxWatch window (director floors this to minCameraDwell). return age < Mathf.Max(candidate.MinWatch, candidate.MaxWatch); case InterestCompletionKind.CombatActive: return CombatStillActive(candidate); case InterestCompletionKind.WarFront: return WarFrontStillActive(candidate); case InterestCompletionKind.PlotActive: return PlotStillActive(candidate); case InterestCompletionKind.FamilyPack: return FamilyPackStillActive(candidate); case InterestCompletionKind.StatusOutbreak: return StatusOutbreakStillActive(candidate); case InterestCompletionKind.EarthquakeActive: return EarthquakeStillActive(candidate); case InterestCompletionKind.ActivityActive: return ActivityStillActive(candidate); case InterestCompletionKind.HappinessGrief: return GriefStillActive(candidate); case InterestCompletionKind.StatusPhase: return StatusStillActive(candidate); case InterestCompletionKind.CharacterVignette: return VignetteStillActive(candidate, age); default: return age < candidate.MinWatch; } } private static bool EarthquakeStillActive(InterestCandidate c) { _ = c; try { return Earthquake.isQuakeActive(); } catch { return false; } } private static bool CombatStillActive(InterestCandidate c) { if (InterestDirector.HarnessCombatForcedCold) { return false; } Actor unit = c.FollowUnit; if (unit == null || !unit.isAlive()) { return false; } float now = Time.unscaledTime; bool hot = false; try { if (unit.has_attack_target) { hot = true; } else if (unit.hasTask() && unit.ai?.task != null && (unit.ai.task.in_combat || unit.ai.task.is_fireman)) { hot = true; } else if (RelatedStillFightingUs(c, unit)) { hot = true; } } catch { // ignore } if (!hot && c.AssetId == "live_battle") { // Chunk-local only - never CollectAllCombatFighters during sticky maintain. Vector3 pos = c.Position; try { if (c.FollowUnit != null && c.FollowUnit.isAlive()) { pos = c.FollowUnit.current_position; } } catch { // keep Position } hot = WorldActivityScanner.HasLiveCombatNear(pos, 18f); } if (hot) { // Refresh so brief attack_target gaps do not clear reason / open quiet grace. c.LastSeenAt = now; return true; } // Hysteresis: WorldBox clears attack_target between swings. const float combatHysteresisSeconds = 8f; return now - c.LastSeenAt < combatHysteresisSeconds; } private static bool WarFrontStillActive(InterestCandidate c) { if (c == null) { return false; } float now = Time.unscaledTime; if (!c.HasStickyCombatSides) { return false; } // Promote path may briefly clear follow; keep hot while sides resolve. if (!c.HasFollowUnit) { return now - c.LastSeenAt < 8f; } Kingdom a = LiveEnsemble.FindKingdomByKey(c.CombatSideAKey); Kingdom b = LiveEnsemble.FindKingdomByKey(c.CombatSideBKey); bool sidesLive = (a != null || b != null); if (!sidesLive && !string.IsNullOrEmpty(c.CorrelationKey)) { sidesLive = TryResolveWarStillActive(c.CorrelationKey); } if (sidesLive) { c.LastSeenAt = now; return true; } return now - c.LastSeenAt < 8f; } private static bool PlotStillActive(InterestCandidate c) { if (c == null) { return false; } float now = Time.unscaledTime; if (!c.HasStickyCombatSides) { return false; } if (!c.HasFollowUnit) { return now - c.LastSeenAt < 8f; } // PlotCell sticky needs a real conspiracy cell (2+ plotters). Solo leftovers end. int plotters = 0; LiveSceneStickyState sticky = c.Sticky; if (sticky != null) { for (int i = 0; i < sticky.SideAIds.Count; i++) { Actor unit = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]); if (unit != null && unit.isAlive()) { plotters++; } } } if (plotters < 2 && !string.IsNullOrEmpty(c.CorrelationKey)) { plotters = Math.Max(plotters, CountLivePlotters(c.CorrelationKey)); } // Harness may pin SideACount above the enrolled roster size. if (plotters < 2 && sticky != null && sticky.SideACount >= 2 && sticky.SideAIds.Count >= 2) { plotters = sticky.SideACount; } if (plotters >= 2) { c.LastSeenAt = now; return true; } return now - c.LastSeenAt < 4f; } private static bool FamilyPackStillActive(InterestCandidate c) { if (c == null) { return false; } float now = Time.unscaledTime; if (!c.HasStickyCombatSides) { return false; } if (!c.HasFollowUnit) { return now - c.LastSeenAt < 8f; } // Pack scenes need a real pack (2+). Solo leftovers end into quiet grace. int members = CountLivingPackMembers(c); if (members < 2 && !string.IsNullOrEmpty(c.CorrelationKey)) { members = TryResolveFamilyMemberCount(c.CorrelationKey); } if (members >= 2) { c.LastSeenAt = now; return true; } return now - c.LastSeenAt < 4f; } private static int CountLivingPackMembers(InterestCandidate c) { LiveSceneStickyState sticky = c?.Sticky; if (sticky == null) { return 0; } int members = 0; for (int i = 0; i < sticky.SideAIds.Count; i++) { Actor unit = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]); if (unit != null && unit.isAlive()) { members++; } } return members; } private static int TryResolveFamilyMemberCount(string familyKey) { if (string.IsNullOrEmpty(familyKey) || World.world?.families == null) { return 0; } try { foreach (Family family in World.world.families) { if (family == null) { continue; } string id = ""; try { id = family.getID().ToString(); } catch { id = ""; } if (string.IsNullOrEmpty(id) || familyKey.IndexOf(id, StringComparison.Ordinal) < 0) { continue; } try { return family.countUnits(); } catch { var members = new System.Collections.Generic.List(4); LiveEnsemble.CollectFamilyUnits(family, members); return members.Count; } } } catch { // ignore } return 0; } private static bool TryResolvePlotStillActive(string plotKey) { if (string.IsNullOrEmpty(plotKey) || World.world?.plots == null) { return false; } try { foreach (Plot plot in World.world.plots) { if (plot == null) { continue; } string id = ""; try { id = plot.getID().ToString(); } catch { id = ""; } string assetId = ""; try { PlotAsset asset = plot.getAsset(); if (asset != null) { assetId = asset.id ?? ""; } } catch { assetId = ""; } bool match = (!string.IsNullOrEmpty(id) && plotKey.IndexOf(id, StringComparison.Ordinal) >= 0) || (!string.IsNullOrEmpty(assetId) && plotKey.IndexOf(assetId, StringComparison.OrdinalIgnoreCase) >= 0); if (!match) { continue; } try { if (!plot.isActive()) { return false; } } catch { // treat as active when isActive is unavailable } return CountPlotUnits(plot) >= 2; } } catch { // ignore } return false; } private static int CountLivePlotters(string plotKey) { if (string.IsNullOrEmpty(plotKey) || World.world?.plots == null) { return 0; } try { foreach (Plot plot in World.world.plots) { if (plot == null) { continue; } string id = ""; try { id = plot.getID().ToString(); } catch { id = ""; } string assetId = ""; try { PlotAsset asset = plot.getAsset(); if (asset != null) { assetId = asset.id ?? ""; } } catch { assetId = ""; } bool match = (!string.IsNullOrEmpty(id) && plotKey.IndexOf(id, StringComparison.Ordinal) >= 0) || (!string.IsNullOrEmpty(assetId) && plotKey.IndexOf(assetId, StringComparison.OrdinalIgnoreCase) >= 0); if (!match) { continue; } return CountPlotUnits(plot); } } catch { // ignore } return 0; } private static int CountPlotUnits(Plot plot) { if (plot == null) { return 0; } var units = new List(8); LiveEnsemble.CollectPlotUnits(plot, units); try { Actor author = plot.getAuthor(); if (author != null && author.isAlive() && !units.Contains(author)) { units.Add(author); } } catch { // ignore } int n = 0; for (int i = 0; i < units.Count; i++) { if (units[i] != null && units[i].isAlive()) { n++; } } return n; } private static bool TryResolveWarStillActive(string warKey) { if (string.IsNullOrEmpty(warKey) || World.world?.wars == null) { return false; } try { foreach (War war in World.world.wars) { if (war == null) { continue; } string id = ""; try { id = war.getID().ToString(); } catch { id = ""; } if (!string.IsNullOrEmpty(id) && warKey.IndexOf(id, StringComparison.Ordinal) >= 0) { try { return war.isAlive(); } catch { return true; } } } } catch { // ignore } return false; } private static bool RelatedStillFightingUs(InterestCandidate c, Actor unit) { Actor foe = c.RelatedUnit; if (foe == null || !foe.isAlive()) { return false; } try { if (!foe.has_attack_target || foe.attack_target == null || !foe.attack_target.isAlive()) { return false; } if (!foe.attack_target.isActor()) { return false; } return foe.attack_target.a == unit; } catch { return false; } } private static bool ActivityStillActive(InterestCandidate c) { Actor unit = c.FollowUnit; if (unit == null || !unit.isAlive()) { return false; } ActivityBand band = ActivityInterestTable.Classify(unit); return band >= ActivityBand.Warm || unit.has_attack_target; } private static bool GriefStillActive(InterestCandidate c) { Actor survivor = c.FollowUnit; if (survivor == null || !survivor.isAlive()) { return false; } if (!string.IsNullOrEmpty(c.StatusId) && HasStatus(survivor, c.StatusId)) { return true; } // Short grief window after the happiness signal. return Time.unscaledTime - c.LastSeenAt < 8f; } private static bool StatusStillActive(InterestCandidate c) { Actor unit = c.FollowUnit; if (unit == null || !unit.isAlive() || string.IsNullOrEmpty(c.StatusId)) { return false; } return HasStatus(unit, c.StatusId); } private static bool StatusOutbreakStillActive(InterestCandidate c) { if (c == null || string.IsNullOrEmpty(c.StatusId)) { return false; } float now = Time.unscaledTime; if (!c.HasStickyCombatSides) { return false; } if (!c.HasFollowUnit) { return now - c.LastSeenAt < 8f; } // Outbreak stickies need a real cluster (2+). Solo leftovers end into quiet grace. int carriers = 0; LiveSceneStickyState sticky = c.Sticky; if (sticky != null) { for (int i = 0; i < sticky.SideAIds.Count; i++) { Actor unit = LiveEnsemble.FindTrackedActor(sticky.SideAIds[i]); if (unit != null && unit.isAlive() && HasStatus(unit, c.StatusId)) { carriers++; } } } if (carriers < 2 && c.FollowUnit != null && HasStatus(c.FollowUnit, c.StatusId)) { carriers = Math.Max(carriers, 1); } if (carriers >= 2) { c.LastSeenAt = now; return true; } return now - c.LastSeenAt < 4f; } private static bool VignetteStillActive(InterestCandidate c, float age) { if (age < c.MinWatch) { return true; } Actor unit = c.FollowUnit; if (unit == null || !unit.isAlive()) { return false; } // Stay while still warm; cold ends after min watch. return !WorldActivityScanner.IsFocusActivityCold(unit); } private static bool HasStatus(Actor actor, string statusId) { if (actor == null || string.IsNullOrEmpty(statusId)) { return false; } try { return actor.hasStatus(statusId); } catch { return false; } } }