- Implement new commands for marking combat cold and pushing interest arcs - Update InterestDirector to manage forced cold combat states for peers - Introduce frequency adjustments for scoring based on recent arc representation - Refactor scoring weights to include combat urgency and frequency penalties - Increment version to 0.28.38 in mod.json and scoring-model.json
711 lines
18 KiB
C#
711 lines
18 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;
|
|
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<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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|