using System;
using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
///
/// Scores live world activity across civs and wild mobs (wiki-aligned).
/// Throttled to ~2 Hz while Spectator is active.
///
public static class WorldActivityScanner
{
private static readonly HashSet SpectacleAssets = new HashSet
{
"dragon",
"greg",
"demon",
"evil_mage",
"necromancer",
"ufo",
"cold_one",
"walker",
"snowman",
"bandit",
"tumor_monster",
"bioblob",
"assimilator",
"lil_pumpkin",
"fire_elemental",
"skeleton",
"zombie",
"ghost",
"mush"
};
private static readonly System.Random Rng = new System.Random();
private static float _lastScanAt = -999f;
private static InterestEvent _cachedBest;
private static InterestEvent _cachedBattle;
private const float ScanInterval = 0.5f;
private static float _lastEmptyLogAt = -999f;
public static InterestEvent FindBestLiveTarget()
{
EnsureScanned();
if (_cachedBest == null && Time.unscaledTime - _lastEmptyLogAt > 15f)
{
_lastEmptyLogAt = Time.unscaledTime;
LogService.LogInfo("[IdleSpectator] No live units to follow yet");
}
return Clone(_cachedBest);
}
public static InterestEvent FindHottestBattle()
{
EnsureScanned();
return Clone(_cachedBattle);
}
/// Register up to character-led vignette seeds into the registry.
public static void RegisterTopCharacterCandidates(int max = 4)
{
EnsureScanned();
Dictionary speciesCounts = CountSpeciesPopulations();
List alive = new List(128);
foreach (Actor actor in EnumerateAliveUnits())
{
if (actor?.asset != null && actor.asset.is_boat)
{
continue;
}
if (actor != null && actor.isAlive())
{
alive.Add(actor);
}
}
if (alive.Count == 0)
{
return;
}
alive.Sort((a, b) => ScoreActor(b, speciesCounts).CompareTo(ScoreActor(a, speciesCounts)));
int n = Mathf.Min(max, alive.Count);
for (int i = 0; i < n; i++)
{
Actor actor = alive[i];
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
{
continue;
}
long id = 0;
try
{
id = actor.getID();
}
catch
{
continue;
}
Actor foe = null;
try
{
if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor())
{
foe = actor.attack_target.a;
if (foe != null && !foe.isAlive())
{
foe = null;
}
}
}
catch
{
foe = null;
}
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
int fighters = ensemble != null ? ensemble.ParticipantCount : 1;
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
fighters = Mathf.Max(1, fighters);
if (ensemble != null && ensemble.HasFocus)
{
if (ensemble.ParticipantCount > 2
|| LiveEnsemble.HasOpposingCollectiveSides(ensemble)
|| ensemble.Scale > EnsembleScale.Pair)
{
follow = ensemble.Focus;
related = ensemble.Related ?? related;
}
else
{
related = related ?? ensemble.Related;
}
}
string label = ensemble != null
? EventReason.Combat(ensemble)
: EventReason.Fight(follow, related);
var candidate = new InterestCandidate
{
Key = EventCatalog.Combat.KeyFor(follow ?? actor, related),
LeadKind = InterestLeadKind.EventLed,
Category = "Combat",
Source = "scanner",
EventStrength = Mathf.Max(actionScore, EventCatalog.Combat.ScannerStrengthFloor),
CharacterSignificance = charScore,
VisualConfidence = 0.7f,
Position = follow != null ? follow.current_position : actor.current_position,
FollowUnit = follow,
RelatedUnit = related,
SubjectId = follow != null ? EventFeedUtil.SafeId(follow) : id,
RelatedId = related != null ? EventFeedUtil.SafeId(related) : 0,
Label = label,
Verb = "fighting",
AssetId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : "scored_unit"),
SpeciesId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : ""),
CityKey = follow?.city != null ? (follow.city.name ?? "") : (actor.city != null ? (actor.city.name ?? "") : ""),
KingdomKey = follow?.kingdom != null ? (follow.kingdom.name ?? "") : (actor.kingdom != null ? (actor.kingdom.name ?? "") : ""),
ParticipantCount = fighters,
NotableParticipantCount = notables,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ScannerTtl,
MinWatch = EventCatalog.Combat.MinWatch,
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
candidate.StampCombatPair(follow ?? actor, related);
if (ensemble != null)
{
StampParticipantIds(candidate, ensemble);
if (ensemble.Scale >= EnsembleScale.Skirmish)
{
candidate.AssetId = "live_battle";
}
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)
&& ensemble.Frame == EnsembleFrame.KingdomVsKingdom)
{
candidate.KingdomKey = ensemble.SideA.Key;
}
}
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
}
private static void EnsureScanned()
{
if (Time.unscaledTime - _lastScanAt < ScanInterval)
{
return;
}
_lastScanAt = Time.unscaledTime;
ScanNow();
}
private static void ScanNow()
{
_cachedBattle = BuildHottestBattle();
InterestEvent bestUnit = BuildBestScoredUnit();
_cachedBest = PreferRicherAction(_cachedBattle, bestUnit);
}
///
/// Pick battle vs unit dynamically: mass fights win unless a notable 1v1 outranks an anonymous melee.
///
private static InterestEvent PreferRicherAction(InterestEvent battle, InterestEvent unit)
{
if (battle == null)
{
return unit;
}
if (unit == null)
{
return battle;
}
bool unitDuel = unit.FollowUnit != null
&& unit.FollowUnit.has_attack_target
&& unit.ParticipantCount > 0
&& unit.ParticipantCount <= InterestScoringConfig.W.duelMaxFighters
&& unit.NotableParticipantCount >= 2;
bool battleAnonymousMelee = battle.ParticipantCount >= InterestScoringConfig.W.massFighterThreshold
&& battle.NotableParticipantCount == 0;
if (unitDuel && battleAnonymousMelee
&& unit.Score + InterestScoringConfig.W.preferNotableDuelMargin >= battle.Score)
{
return unit;
}
return battle.Score >= unit.Score ? battle : unit;
}
private static InterestEvent BuildHottestBattle()
{
HashSet battles = BattleKeeperManager.get();
if (battles == null || battles.Count == 0)
{
// Fall through to live fight clusters with no BattleKeeper entry yet.
return BuildHottestFightCluster();
}
InterestEvent best = null;
float bestScore = float.MinValue;
foreach (BattleContainer battle in battles)
{
if (battle?.tile == null)
{
continue;
}
int deaths = battle.getDeathsTotal();
Vector3 pos = battle.tile.posV3;
LiveEnsemble.TryBuildCombat(pos, 14f, out LiveEnsemble ensemble);
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
Actor follow = ensemble != null ? ensemble.Focus : null;
if (deaths < 1 && fighters < 2)
{
continue;
}
fighters = Mathf.Max(fighters, deaths > 0 ? 2 : 0);
float score = ScoreFightCluster(deaths, fighters, notables);
if (score > bestScore)
{
bestScore = score;
Actor focus = follow ?? FindNearestAliveUnit(pos, 14f);
string label = ensemble != null && ensemble.HasFocus
? EventReason.Combat(ensemble)
: EventReason.Battle(focus, fighters);
best = new InterestEvent
{
Score = score,
Position = pos,
FollowUnit = focus,
RelatedUnit = ensemble != null ? ensemble.Related : null,
Label = label,
CreatedAt = Time.unscaledTime,
AssetId = "live_battle",
ParticipantCount = fighters,
NotableParticipantCount = notables,
CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable
};
}
}
InterestEvent live = BuildHottestFightCluster();
return PreferRicherAction(best, live);
}
/// Cluster of units currently in combat (even before BattleKeeper records deaths).
private static InterestEvent BuildHottestFightCluster()
{
InterestEvent best = null;
float bestScore = float.MinValue;
var seen = new HashSet();
foreach (Actor actor in EnumerateAliveUnits())
{
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
{
continue;
}
long id = 0;
try
{
id = actor.getID();
}
catch
{
continue;
}
if (seen.Contains(id))
{
continue;
}
LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
Actor follow = ensemble != null ? ensemble.Focus : null;
if (fighters < 1)
{
continue;
}
// Mark cluster members so we don't emit N duplicates.
MarkClusterSeen(actor.current_position, 10f, seen);
float score = ScoreFightCluster(deaths: 0, fighters, notables);
if (score > bestScore)
{
bestScore = score;
Actor focus = follow ?? actor;
string label = ensemble != null && ensemble.HasFocus
? EventReason.Combat(ensemble)
: EventReason.Battle(focus, fighters);
best = new InterestEvent
{
Score = score,
Position = actor.current_position,
FollowUnit = focus,
RelatedUnit = ensemble != null ? ensemble.Related : null,
Label = label,
CreatedAt = Time.unscaledTime,
AssetId = "live_battle",
ParticipantCount = fighters,
NotableParticipantCount = notables,
CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable
};
}
}
return best;
}
///
/// Character weight for picking who to watch in a fight (notable / renown / role).
/// Matches bestFollow ranking.
///
public static float CombatFocusWeight(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return -1f;
}
SplitActorScore(actor, null, out _, out float charScore);
float weight = charScore;
if (InterestScoring.IsExtremelyNotable(actor))
{
weight += 30f;
}
return weight;
}
///
/// Prefer the higher-scored of two combatants as follow; the other becomes related.
///
public static void PreferCombatFollow(Actor a, Actor b, out Actor follow, out Actor related)
{
// Stable ownership: never swap A↔B by momentary weight (that thrashes Duel tips).
// Caller / first claimant stays Follow; dead follow hands off to related.
follow = a;
related = b;
if (a == null || !a.isAlive())
{
follow = b != null && b.isAlive() ? b : null;
related = null;
return;
}
if (b == null || !b.isAlive())
{
related = null;
}
}
///
/// Highest-scored living participant for an active combat scene.
/// Considers Follow, Related, and (for mass fights) the nearby fight ensemble.
///
public static bool TryPickBestCombatFocus(
InterestCandidate scene,
out Actor best,
out Actor foe,
out int fighters)
{
best = null;
foe = null;
fighters = 1;
if (scene == null)
{
return false;
}
LiveEnsemble ensemble = null;
Vector3 pos = scene.Position;
if (scene.FollowUnit != null && scene.FollowUnit.isAlive())
{
pos = scene.FollowUnit.current_position;
}
else if (scene.RelatedUnit != null && scene.RelatedUnit.isAlive())
{
pos = scene.RelatedUnit.current_position;
}
bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| scene.ParticipantCount > 2;
float radius = mass ? 14f : 10f;
LiveEnsemble.TryBuildCombat(
pos,
radius,
out ensemble,
priorParticipantIds: scene.ParticipantIds,
newcomerBonus: 8f);
if (ensemble != null && ensemble.HasFocus)
{
best = ensemble.Focus;
foe = ensemble.Related;
fighters = Mathf.Max(1, ensemble.ParticipantCount);
return true;
}
fighters = Mathf.Max(1, scene.ParticipantCount);
float bestWeight = -1f;
Actor bestLocal = null;
void Consider(Actor unit)
{
if (unit == null || !unit.isAlive())
{
return;
}
float w = LiveEnsemble.FocusWeight(unit, scene.ParticipantIds, 8f);
if (w > bestWeight)
{
bestWeight = w;
bestLocal = unit;
}
}
Consider(scene.FollowUnit);
Consider(scene.RelatedUnit);
best = bestLocal;
if (best == null)
{
return false;
}
foe = null;
try
{
if (best.has_attack_target
&& best.attack_target != null
&& best.attack_target.isAlive()
&& best.attack_target.isActor())
{
foe = best.attack_target.a;
if (foe != null && !foe.isAlive())
{
foe = null;
}
}
}
catch
{
foe = null;
}
if (foe == null)
{
Actor related = scene.RelatedUnit;
Actor follow = scene.FollowUnit;
if (related != null && related.isAlive() && related != best)
{
foe = related;
}
else if (follow != null && follow.isAlive() && follow != best)
{
foe = follow;
}
}
return true;
}
/// Build a combat ensemble snapshot at a world position.
public static bool TryBuildCombatEnsemble(
Vector2 pos,
float radius,
out LiveEnsemble ensemble,
InterestCandidate prior = null)
{
return LiveEnsemble.TryBuildCombat(
pos,
radius,
out ensemble,
priorParticipantIds: prior?.ParticipantIds,
newcomerBonus: 8f);
}
public static void StampParticipantIds(InterestCandidate candidate, LiveEnsemble ensemble)
{
if (candidate == null || ensemble == null)
{
return;
}
candidate.ParticipantIds.Clear();
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
{
long id = ensemble.ParticipantIds[i];
if (id != 0 && !candidate.ParticipantIds.Contains(id))
{
candidate.ParticipantIds.Add(id);
}
}
}
private static float ScoreFightCluster(int deaths, int fighters, int notables)
{
ScoringWeights w = InterestScoringConfig.W;
float score = deaths * w.clusterDeathWeight
+ Mathf.Max(0, fighters) * w.clusterFighterWeight
+ notables * w.clusterNotableWeight;
// Multi-person melees get a clear bump over thin 1v1s.
if (fighters >= w.massFighterThreshold)
{
score += w.clusterMassBonus;
}
else if (fighters <= w.duelMaxFighters)
{
score += w.clusterDuelPenalty;
// Notable duels recover - king vs king / favorites.
if (notables >= 2)
{
score += w.clusterNotableDuelBonus;
}
else if (notables == 1)
{
score += w.clusterSingleNotableBonus;
}
}
return score;
}
private static void CountFightCluster(
Vector2 pos,
float radius,
out int fighters,
out int notables,
out Actor bestFollow)
{
fighters = 0;
notables = 0;
bestFollow = null;
if (!LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble))
{
return;
}
fighters = ensemble.ParticipantCount;
notables = ensemble.NotableParticipantCount;
bestFollow = ensemble.Focus;
}
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet seen)
{
float r2 = radius * radius;
foreach (Actor actor in EnumerateAliveUnits())
{
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
{
continue;
}
float dx = actor.current_position.x - pos.x;
float dy = actor.current_position.y - pos.y;
if (dx * dx + dy * dy > r2)
{
continue;
}
try
{
seen.Add(actor.getID());
}
catch
{
// ignore
}
}
}
private static InterestEvent BuildBestScoredUnit()
{
Dictionary speciesCounts = new Dictionary();
List alive = new List(128);
foreach (Actor actor in EnumerateAliveUnits())
{
if (actor.asset != null && actor.asset.is_boat)
{
continue;
}
alive.Add(actor);
string speciesId = actor.asset != null ? actor.asset.id : "unknown";
speciesCounts.TryGetValue(speciesId, out int count);
speciesCounts[speciesId] = count + 1;
}
if (alive.Count == 0)
{
return null;
}
Actor best = null;
float bestScore = float.MinValue;
List nearTies = new List();
for (int i = 0; i < alive.Count; i++)
{
Actor actor = alive[i];
float unitScore = ScoreActor(actor, speciesCounts);
if (unitScore > bestScore + 0.01f)
{
bestScore = unitScore;
best = actor;
nearTies.Clear();
nearTies.Add(actor);
}
else if (Mathf.Abs(unitScore - bestScore) <= 2f)
{
nearTies.Add(actor);
}
}
if (nearTies.Count > 1)
{
best = nearTies[Rng.Next(nearTies.Count)];
bestScore = ScoreActor(best, speciesCounts);
}
if (best == null)
{
return null;
}
ActivityBand band = ActivityInterestTable.Classify(best);
SplitActorScore(best, speciesCounts, out float actionScore, out float charScore);
int fighters = 0;
int notables = 0;
if (best.has_attack_target || band == ActivityBand.Hot)
{
CountFightCluster(best.current_position, 10f, out fighters, out notables, out _);
fighters = Mathf.Max(1, fighters);
}
float finalScore = ScoreFightCluster(0, fighters, notables);
float rankChar = InterestScoringConfig.W.scannerRankCharWeight;
if (fighters <= 0)
{
finalScore = actionScore + charScore * rankChar;
}
else
{
finalScore = Mathf.Max(finalScore, actionScore + charScore * rankChar);
}
string label = "";
if (best.has_attack_target)
{
Actor foe = null;
try
{
if (best.attack_target != null && best.attack_target.isAlive() && best.attack_target.isActor())
{
foe = best.attack_target.a;
}
}
catch
{
foe = null;
}
label = EventReason.Fight(best, foe);
}
return new InterestEvent
{
Score = finalScore,
Position = best.current_position,
FollowUnit = best,
Label = label,
CreatedAt = Time.unscaledTime,
AssetId = best.asset != null ? best.asset.id : "scored_unit",
ParticipantCount = fighters,
NotableParticipantCount = notables,
CharacterSignificance = charScore
};
}
/// Public wrappers for dossier / harness (same logic as private scorers).
public static Dictionary CountSpeciesPopulations()
{
Dictionary speciesCounts = new Dictionary();
foreach (Actor actor in EnumerateAliveUnits())
{
if (actor?.asset == null)
{
continue;
}
string id = actor.asset.id;
if (!speciesCounts.ContainsKey(id))
{
speciesCounts[id] = 0;
}
speciesCounts[id]++;
}
return speciesCounts;
}
public static bool HarnessActivityFirstScoringOk(out string detail)
{
// Mirror ScoreActor importance gating with synthetic bands.
float Cold(float importance) =>
ComposeScore(4f, ActivityBand.Cold, importance);
float Warm(float importance) =>
ComposeScore(28f, ActivityBand.Warm, importance);
float Hot(float importance) =>
ComposeScore(55f, ActivityBand.Hot, importance);
float coldKingFav = Cold(40f); // favorite + king
float hotPeasant = Hot(0f);
float warmFav = Warm(22f);
float coldPeasant = Cold(0f);
bool ok = hotPeasant > coldKingFav
&& warmFav > coldKingFav
&& hotPeasant > warmFav
&& coldKingFav > coldPeasant;
detail =
$"coldKingFav={coldKingFav:0.0} hotPeasant={hotPeasant:0.0} warmFav={warmFav:0.0} coldPeasant={coldPeasant:0.0}";
return ok;
}
private static float ComposeScore(float activity, ActivityBand band, float importance)
{
if (band >= ActivityBand.Warm || activity >= ActivityInterestTable.WarmScoreFloor)
{
return activity + importance * 0.85f;
}
return activity + importance * 0.08f;
}
public static float ScoreActorPublic(Actor actor, Dictionary speciesCounts)
{
return ScoreActor(actor, speciesCounts);
}
public static bool IsSpectaclePublic(Actor actor)
{
return IsSpectacle(actor);
}
public static bool IsSingletonSpeciesPublic(Actor actor, Dictionary speciesCounts)
{
return IsSingletonSpecies(actor, speciesCounts);
}
public static string FormatUnitLabelPublic(Actor actor, Dictionary speciesCounts)
{
return FormatUnitLabel(actor, speciesCounts);
}
private static float ScoreActor(Actor actor, Dictionary speciesCounts)
{
SplitActorScore(actor, speciesCounts, out float action, out float character);
// Action-primary total used for ranking; character is a light tie-break.
return action + character * InterestScoringConfig.W.scannerRankCharWeight;
}
///
/// Split live action intensity from who the unit is.
/// Same action → higher character wins; character alone never dominates a real fight.
///
private static void SplitActorScore(
Actor actor,
Dictionary speciesCounts,
out float actionScore,
out float characterScore)
{
actionScore = 0f;
characterScore = 0f;
if (actor == null)
{
return;
}
ScoringWeights w = InterestScoringConfig.W;
float activity = ActivityInterestTable.ScoreActivity(actor);
ActivityBand band = ActivityInterestTable.Classify(actor);
actionScore = activity;
if (actor.has_attack_target)
{
actionScore += w.scannerAttackBonus;
}
if (band == ActivityBand.Hot)
{
actionScore += w.scannerHotBonus;
}
else if (band == ActivityBand.Warm)
{
actionScore += w.scannerWarmBonus;
}
if (IsSpectacle(actor))
{
actionScore += w.scannerSpectacleBonus;
}
int kills = actor.data != null ? actor.data.kills : 0;
if (band >= ActivityBand.Warm || actor.has_attack_target)
{
actionScore += Mathf.Min(w.scannerActionKillsCap, kills * w.scannerActionKillsFactor);
}
// Character significance - tie-break / amplifier only.
if (actor.isFavorite())
{
characterScore += w.metaFavorite;
}
if (actor.isKing())
{
characterScore += w.metaKing;
}
else if (actor.isCityLeader())
{
characterScore += w.metaLeader;
}
else if (actor.is_profession_warrior)
{
characterScore += w.scannerWarriorBonus;
}
string speciesId = actor.asset != null ? actor.asset.id : "unknown";
if (speciesCounts != null
&& speciesCounts.TryGetValue(speciesId, out int pop)
&& pop == 1)
{
characterScore += w.scannerSingletonBonus;
}
float renownDiv = w.scannerCharRenownDivisor > 0f ? w.scannerCharRenownDivisor : 120f;
characterScore += Mathf.Min(w.scannerCharRenownCap, actor.renown / renownDiv);
characterScore += Mathf.Min(w.scannerCharKillsCap, kills * w.scannerCharKillsFactor);
// Cold kings barely score - do not let identity monopolize the camera.
if (band == ActivityBand.Cold && !actor.has_attack_target && !IsSpectacle(actor))
{
actionScore *= w.scannerColdActionMul;
characterScore *= w.scannerColdCharMul;
}
actionScore += (actor.getID() % 7) * 0.01f;
}
/// True when the focused unit's current activity is Cold (idle scoring).
public static bool IsFocusActivityCold(Actor actor)
{
return ActivityInterestTable.Classify(actor) == ActivityBand.Cold
&& (actor == null || !actor.has_attack_target);
}
private static int CountSubspeciesMembers(Subspecies subspecies)
{
int count = 0;
// Prefer actor.asset.units filtered by subspecies when possible.
if (subspecies == null)
{
return 0;
}
foreach (Actor unit in EnumerateAliveUnits())
{
if (unit.subspecies == subspecies)
{
count++;
if (count > 8)
{
return count;
}
}
}
return count;
}
private static bool IsSpectacle(Actor actor)
{
if (actor?.asset == null)
{
return false;
}
string id = actor.asset.id;
if (SpectacleAssets.Contains(id))
{
return true;
}
// Loose match for variant ids.
foreach (string key in SpectacleAssets)
{
if (id.Contains(key))
{
return true;
}
}
return false;
}
private static bool IsSingletonSpecies(Actor actor, Dictionary speciesCounts)
{
if (actor?.asset == null)
{
return false;
}
return speciesCounts.TryGetValue(actor.asset.id, out int pop) && pop == 1;
}
private static string FormatUnitLabel(Actor actor, Dictionary speciesCounts)
{
string name = SafeName(actor);
string species = actor.asset != null ? actor.asset.id : "creature";
// Event / activity first - titles alone are not a watch reason (nametag already has the name).
if (actor.has_attack_target)
{
return $"Fighting: {name}";
}
if (IsSpectacle(actor))
{
return $"Spectacle: {name} ({species})";
}
if (actor.isFavorite())
{
return $"Favorite: {name}";
}
if (actor.isKing())
{
string kingdom = actor.kingdom != null ? actor.kingdom.name : "realm";
return $"King of {kingdom}";
}
if (actor.isCityLeader())
{
string city = actor.city != null ? actor.city.name : "town";
return $"Leader of {city}";
}
if (IsSingletonSpecies(actor, speciesCounts))
{
return $"Lone {species}: {name}";
}
int kills = actor.data != null ? actor.data.kills : 0;
if (kills > 0)
{
return $"{name} ({species}, {kills} kills)";
}
return $"{name} ({species})";
}
public static InterestEvent FromNewSpecies(Subspecies subspecies, ActorAsset asset, WorldTile tile)
{
if (asset == null)
{
return null;
}
Vector3 pos = tile != null ? tile.posV3 : Vector3.zero;
Actor follow = FindMatchingSpeciesUnit(asset, subspecies, pos, 30f);
if (follow != null)
{
pos = follow.current_position;
}
// No matching unit yet = ghost tip (wrong creature gets focused). Skip until one exists.
if (follow == null)
{
return null;
}
return new InterestEvent
{
Score = 5f,
Position = pos,
FollowUnit = follow,
Label = $"New species: {asset.id}",
CreatedAt = Time.unscaledTime,
AssetId = asset.id
};
}
///
/// Nearest living unit, optionally restricted to a specific actor asset id.
///
public static Actor FindNearestAliveUnit(Vector3 pos, float maxDist, string requireAssetId = null)
{
// Freshly spawned units are often on asset.units before units_only_alive updates.
if (!string.IsNullOrEmpty(requireAssetId))
{
Actor fromAsset = FindAliveOnAssetList(requireAssetId, pos, maxDist);
if (fromAsset != null)
{
return fromAsset;
}
}
Actor best = null;
float bestDist = maxDist * maxDist;
foreach (Actor actor in EnumerateAliveUnits())
{
if (requireAssetId != null
&& (actor.asset == null || actor.asset.id != requireAssetId))
{
continue;
}
float dx = actor.current_position.x - pos.x;
float dy = actor.current_position.y - pos.y;
float d2 = dx * dx + dy * dy;
if (d2 < bestDist)
{
bestDist = d2;
best = actor;
}
}
return best;
}
///
/// Any living unit of this asset id (asset.units first, then world scan).
///
public static Actor FindAliveByAssetId(string assetId, Vector3 nearPos, float maxDist = 5000f)
{
if (string.IsNullOrEmpty(assetId))
{
return null;
}
return FindNearestAliveUnit(nearPos, maxDist, assetId);
}
private static Actor FindAliveOnAssetList(string assetId, Vector3 nearPos, float maxDist)
{
ActorAsset asset = AssetManager.actor_library?.get(assetId);
if (asset?.units == null)
{
return null;
}
Actor best = null;
float bestDist = maxDist * maxDist;
foreach (Actor unit in asset.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
float dx = unit.current_position.x - nearPos.x;
float dy = unit.current_position.y - nearPos.y;
float d2 = dx * dx + dy * dy;
if (d2 < bestDist)
{
bestDist = d2;
best = unit;
}
}
return best;
}
private static Actor FindMatchingSpeciesUnit(ActorAsset asset, Subspecies subspecies, Vector3 nearPos, float maxDist)
{
if (asset?.units != null)
{
foreach (Actor unit in asset.units)
{
if (unit != null && unit.isAlive() && (subspecies == null || unit.subspecies == subspecies))
{
return unit;
}
}
}
return FindNearestAliveUnit(nearPos, maxDist, asset != null ? asset.id : null);
}
private static IEnumerable EnumerateAliveUnits()
{
return EnumerateAliveUnitsPublic();
}
public static IEnumerable EnumerateAliveUnitsPublic()
{
List prepared = World.world?.units?.units_only_alive;
if (prepared != null && prepared.Count > 0)
{
for (int i = 0; i < prepared.Count; i++)
{
Actor actor = prepared[i];
if (actor != null && actor.isAlive())
{
yield return actor;
}
}
yield break;
}
if (World.world?.units == null)
{
yield break;
}
foreach (Actor actor in World.world.units)
{
if (actor != null && actor.isAlive())
{
yield return actor;
}
}
}
private static string SafeName(Actor actor)
{
try
{
string name = actor.getName();
if (!string.IsNullOrEmpty(name))
{
return name;
}
}
catch
{
// ignore
}
return actor.asset != null ? actor.asset.id : "creature";
}
private static InterestEvent Clone(InterestEvent source)
{
if (source == null)
{
return null;
}
return new InterestEvent
{
Score = source.Score,
Position = source.Position,
FollowUnit = source.FollowUnit,
Label = source.Label,
CreatedAt = Time.unscaledTime,
AssetId = source.AssetId,
ParticipantCount = source.ParticipantCount,
NotableParticipantCount = source.NotableParticipantCount,
CharacterSignificance = source.CharacterSignificance
};
}
}