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); } private static void EnsureScanned() { if (Time.unscaledTime - _lastScanAt < ScanInterval) { return; } _lastScanAt = Time.unscaledTime; ScanNow(); } private static void ScanNow() { _cachedBattle = BuildHottestBattle(); InterestEvent bestUnit = BuildBestScoredUnit(); // Prefer an active battle when it is meaningfully hotter than quiet unit watching. if (_cachedBattle != null) { _cachedBest = _cachedBattle; return; } _cachedBest = bestUnit; } private static InterestEvent BuildHottestBattle() { HashSet battles = BattleKeeperManager.get(); if (battles == null || battles.Count == 0) { return null; } BattleContainer best = null; int bestDeaths = 0; foreach (BattleContainer battle in battles) { if (battle?.tile == null) { continue; } int deaths = battle.getDeathsTotal(); if (deaths > bestDeaths) { bestDeaths = deaths; best = battle; } } if (best == null || bestDeaths < 1) { return null; } Vector3 pos = best.tile.posV3; Actor nearby = FindNearestAliveUnit(pos, 14f); return new InterestEvent { Tier = InterestTier.Action, Score = 50f + bestDeaths, Position = pos, FollowUnit = nearby, Label = bestDeaths >= 6 ? $"Battle ({bestDeaths} fallen)" : $"Skirmish ({bestDeaths} fallen)", CreatedAt = Time.unscaledTime, AssetId = "live_battle" }; } 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 score = ScoreActor(actor, speciesCounts); if (score > bestScore + 0.01f) { bestScore = score; best = actor; nearTies.Clear(); nearTies.Add(actor); } else if (Mathf.Abs(score - bestScore) <= 2f) { nearTies.Add(actor); } } if (nearTies.Count > 1) { best = nearTies[Rng.Next(nearTies.Count)]; bestScore = ScoreActor(best, speciesCounts); } if (best == null) { return null; } InterestTier tier = InterestTier.Ambient; if (best.isFavorite() || best.isKing() || best.isCityLeader()) { tier = InterestTier.Story; } else if (best.has_attack_target || (best.data != null && best.data.kills >= 10)) { tier = InterestTier.Action; } else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts)) { tier = InterestTier.Curiosity; } return new InterestEvent { Tier = tier, Score = bestScore, Position = best.current_position, FollowUnit = best, Label = FormatUnitLabel(best, speciesCounts), CreatedAt = Time.unscaledTime, AssetId = best.asset != null ? best.asset.id : "scored_unit" }; } /// 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 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) { float score = 0f; if (actor.isFavorite()) { score += 50f; } if (actor.isKing()) { score += 40f; } else if (actor.isCityLeader()) { score += 35f; } else if (actor.is_profession_warrior) { score += 12f; } if (actor.has_attack_target) { score += 30f; } int kills = actor.data != null ? actor.data.kills : 0; score += Mathf.Min(25, kills); string speciesId = actor.asset != null ? actor.asset.id : "unknown"; if (speciesCounts.TryGetValue(speciesId, out int pop) && pop == 1) { score += 15f; } if (actor.subspecies != null) { // Nascent lineage: few members in subspecies asset unit set if available. try { int subCount = CountSubspeciesMembers(actor.subspecies); if (subCount > 0 && subCount <= 3) { score += 10f; } } catch { // ignore } } if (IsSpectacle(actor)) { score += 8f; } score += Mathf.Min(10f, actor.renown / 100f); // Tiny jitter so equal sheep do not stick forever. score += (actor.getID() % 7) * 0.01f; return score; } 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"; if (actor.isFavorite()) { return $"Favorite: {name}"; } if (actor.isKing()) { string kingdom = actor.kingdom != null ? actor.kingdom.name : "realm"; return $"King {name} of {kingdom}"; } if (actor.isCityLeader()) { string city = actor.city != null ? actor.city.name : "town"; return $"Leader {name} of {city}"; } if (actor.has_attack_target) { return $"Fighting: {name}"; } if (IsSpectacle(actor)) { return $"Spectacle: {name} ({species})"; } 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 { Tier = InterestTier.Curiosity, 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 { Tier = source.Tier, Score = source.Score, Position = source.Position, FollowUnit = source.FollowUnit, Label = source.Label, CreatedAt = Time.unscaledTime, AssetId = source.AssetId }; } }