worldbox-observer-mod/IdleSpectator/InterestCompletion.cs
DazedAnon c23b2b9c1e feat(spectator): add crisis chapters and harden combat theater truth
- Open war/disaster/outbreak crisis overlays with epilogue_crisis closers
- Keep Duel pairs hot only on owned cast; park sleep/freeze; drop ambient scrap pins
- Require a fallen theater partner for survivor aftermath; let king_killed cut mid-fight
- Gate crisis, combat cold, king cut-in, and living-partner aftermath in harness
2026-07-21 22:05:38 -05:00

830 lines
21 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Evaluates whether an interest scene is still "live" for protection.</summary>
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;
// Combat / war caps are owned by InterestDirector (MaxWatchFor + CombatFightIsHot).
// An early MaxWatch gate here made live scraps look cold so max_cap re-picked
// a new Duel partner every cap (spectacle 1vN thrash).
if (age >= candidate.MaxWatch
&& candidate.Completion != InterestCompletionKind.CombatActive
&& candidate.Completion != InterestCompletionKind.WarFront)
{
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;
}
float now = Time.unscaledTime;
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{
// Spectacle / pair owner may still be swinging after a brief Follow gap.
unit = ResolveCombatHotAnchor(c);
}
// Single participant predicate: sleeping/stunned/frozen are cold even with a stale
// attack_target pointer (combat_focus natural cold; Norron attack-gap truth).
bool hot = unit != null && unit.isAlive() && LiveEnsemble.IsCombatParticipant(unit);
if (!hot && unit != null && unit.isAlive() && RelatedStillFightingUs(c, unit))
{
hot = true;
}
if (!hot)
{
hot = PairPartnerStillFighting(c);
}
// Collective Mass/Battle: enrolled sticky fighters bridge attack_target gaps.
// Never use unscoped HasLiveCombatNear - ambient scraps within 18 tiles were
// pinning cold Duel pairs (Neen vs Yaaeore held by nearby Igguorn melees).
bool pairOrDuel = IsPairOrDuelCombat(c);
if (!hot && !pairOrDuel && c.HasStickyCombatSides)
{
hot = StickyRosterStillFighting(c);
}
if (hot)
{
// Refresh so brief attack_target gaps do not clear reason / open quiet grace.
c.LastSeenAt = now;
return true;
}
// Pair/Duel: bridge swing gaps. Survivor aftermath requires a fallen partner, so a
// longer pair hold is safe - too short dropped mid-duel into false cold (Norron soak).
const float pairHysteresisSeconds = 5f;
const float combatHysteresisSeconds = 8f;
float hold = pairOrDuel ? pairHysteresisSeconds : combatHysteresisSeconds;
return now - c.LastSeenAt < hold;
}
/// <summary>
/// Pair-locked / Duel tips stay hot only while the owned cast fights - not nearby strangers.
/// </summary>
private static bool IsPairOrDuelCombat(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (c.HasCombatPairLock)
{
return true;
}
string tip = c.Label ?? "";
return tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool PairPartnerStillFighting(InterestCandidate c)
{
if (c == null)
{
return false;
}
Actor partner = c.RelatedUnit;
if ((partner == null || !partner.isAlive()) && c.PairPartnerId != 0)
{
partner = LiveEnsemble.FindTrackedActor(c.PairPartnerId);
}
if (partner == null || !partner.isAlive())
{
return false;
}
try
{
return LiveEnsemble.IsCombatParticipant(partner);
}
catch
{
return false;
}
}
private static bool StickyRosterStillFighting(InterestCandidate c)
{
LiveSceneStickyState sticky = c?.Sticky;
if (sticky == null)
{
return false;
}
if (RosterIdsStillFighting(sticky.SideAIds)
|| RosterIdsStillFighting(sticky.SideBIds))
{
return true;
}
return false;
}
private static bool RosterIdsStillFighting(List<long> ids)
{
if (ids == null || ids.Count == 0)
{
return false;
}
for (int i = 0; i < ids.Count; i++)
{
Actor unit = LiveEnsemble.FindTrackedActor(ids[i]);
if (unit != null && unit.isAlive() && LiveEnsemble.IsCombatParticipant(unit))
{
return true;
}
}
return false;
}
private static Actor ResolveCombatHotAnchor(InterestCandidate c)
{
if (c == null)
{
return null;
}
if (c.TheaterLeadId != 0)
{
Actor lead = LiveEnsemble.FindTrackedActor(c.TheaterLeadId);
if (lead != null && lead.isAlive())
{
return lead;
}
}
if (c.PairOwnerId != 0)
{
Actor owner = LiveEnsemble.FindTrackedActor(c.PairOwnerId);
if (owner != null && owner.isAlive())
{
return owner;
}
}
if (c.RelatedUnit != null && c.RelatedUnit.isAlive())
{
return c.RelatedUnit;
}
return null;
}
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);
}
// Sticky war tips stay hot while opposing camps remain locked, even when kingdom
// asset lookup fails (harness synthetic keys / display-name aliases).
if (!sidesLive && c.HasStickyCombatSides && c.CombatPeakParticipants > 0)
{
sidesLive = true;
}
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<Actor>(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<Actor>(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;
}
if (LiveEnsemble.IsCombatIncapacitated(foe) || LiveEnsemble.IsCombatIncapacitated(unit))
{
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;
}
}
}