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 }; } }