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 { /// Seed ids - live actor_library discovery extends this each refresh. private static readonly string[] SpectacleAssetSeed = { "dragon", "greg", "demon", "evil_mage", "white_mage", "necromancer", "plague_doctor", "druid", "ufo", "cold_one", "walker", "snowman", "bandit", "tumor_monster", "bioblob", "assimilator", "lil_pumpkin", "fire_elemental", "skeleton", "zombie", "ghost", "mush", "crabzilla" }; private static readonly string[] SpectacleIdTokens = { "mage", "necromancer", "druid", "plague_doctor", "dragon", "demon", "cold_one", "tumor", "bioblob", "assimilator", "crabzilla", "fire_elemental", "god_finger" }; private static HashSet _spectacleAssetIds; private static float _spectacleIdsCachedAt = -999f; private const float SpectacleIdsCacheSeconds = 30f; 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; private static Dictionary _speciesCountCache; private static float _speciesCountCachedAt = -999f; private const float SpeciesCountCacheSeconds = 2.5f; private static List _lastAllFighters; private static float _lastAllFightersAt = -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); } /// /// Cheap battle probe for sticky combat: BattleKeeper tiles + one chunk cluster at /// . Never walks every alive unit. /// public static InterestEvent FindHottestBattleLight(Vector3 anchor) { return Clone(BuildHottestBattleLight(anchor)); } /// True when a combat cluster exists near (chunk-local). public static bool HasLiveCombatNear(Vector3 pos, float radius = 16f) { if (LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble) && ensemble != null && ensemble.ParticipantCount >= 1) { return true; } HashSet battles = BattleKeeperManager.get(); if (battles == null || battles.Count == 0) { return false; } float r2 = radius * radius; foreach (BattleContainer battle in battles) { if (battle?.tile == null) { continue; } Vector3 bpos = battle.tile.posV3; float dx = bpos.x - pos.x; float dy = bpos.y - pos.y; if (dx * dx + dy * dy > r2) { continue; } // Deaths alone are not live combat - finished battle tiles near a cold unit // used to pin CombatStillActive forever. if (LiveEnsemble.TryBuildCombat(bpos, 14f, out LiveEnsemble near) && near != null && near.ParticipantCount >= 1) { return true; } } return false; } /// /// Register up to character-led vignette seeds into the registry. /// Samples current combatants only - never sorts the full alive unit list. /// public static void RegisterTopCharacterCandidates(int max = 4) { // Sticky mass combat already owns the camera; skip redundant vignette work. InterestCandidate current = InterestDirector.CurrentCandidate; if (current != null && current.Completion == InterestCompletionKind.CombatActive && current.ParticipantCount >= 4 && InterestDirector.CurrentIsActive) { return; } EnsureScanned(); List fighters = _lastAllFighters; if (fighters == null || fighters.Count == 0 || Time.unscaledTime - _lastAllFightersAt > ScanInterval * 2f) { fighters = new List(64); LiveEnsemble.CollectAllCombatFighters(fighters); _lastAllFighters = fighters; _lastAllFightersAt = Time.unscaledTime; } if (fighters.Count == 0) { return; } Dictionary speciesCounts = CountSpeciesPopulations(); // Linear top-N over fighters only (no O(n log n) sort of the whole world). var top = new List(max); var topScores = new List(max); for (int i = 0; i < fighters.Count; i++) { Actor actor = fighters[i]; if (actor == null || !actor.isAlive() || !actor.has_attack_target) { continue; } float score = ScoreActor(actor, speciesCounts); if (top.Count < max) { top.Add(actor); topScores.Add(score); continue; } int worst = 0; for (int t = 1; t < topScores.Count; t++) { if (topScores[t] < topScores[worst]) { worst = t; } } if (score > topScores[worst]) { top[worst] = actor; topScores[worst] = score; } } var nearby = new List(32); for (int i = 0; i < top.Count; i++) { Actor actor = top[i]; if (actor == null || !actor.isAlive() || !actor.has_attack_target) { continue; } long id = EventFeedUtil.SafeId(actor); if (id == 0) { 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); nearby.Clear(); LiveEnsemble.CollectCombatFightersNear(fighters, actor.current_position, 10f, nearby); LiveEnsemble ensemble = LiveEnsemble.FromCombatFighters(nearby, actor.current_position); int participantCount = ensemble != null ? ensemble.ParticipantCount : 1; int notables = ensemble != null ? ensemble.NotableParticipantCount : 0; participantCount = Mathf.Max(1, participantCount); 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 = participantCount, 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(); // Busy war maps: skip full O(units) scoring, but still consider live spectacles // (evil mage / dragon) so mass civ scraps cannot erase them forever. if (_cachedBattle != null && _cachedBattle.ParticipantCount >= 3) { InterestEvent spectacle = BuildBestSpectacleUnit(); if (spectacle != null && (_cachedBattle.FollowUnit == null || !IsSpectacle(_cachedBattle.FollowUnit)) && _cachedBattle.NotableParticipantCount <= 0) { // Anonymous scrap vs a live spectacle - ambient follows the spectacle. _cachedBest = spectacle; return; } _cachedBest = PreferRicherAction(_cachedBattle, spectacle) ?? _cachedBattle; return; } 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() { // One full-world fighter pass shared by BattleKeeper + live cluster ranking. // Avoids the old O(fighters × units) TryBuildCombat-per-seed path. // Sticky combat must not call this - use BuildHottestBattleLight instead. var allFighters = new List(256); LiveEnsemble.CollectAllCombatFighters(allFighters); _lastAllFighters = allFighters; _lastAllFightersAt = Time.unscaledTime; InterestEvent best = RankBattleKeeper(allFighters); InterestEvent live = BuildHottestFightCluster(allFighters); return PreferRicherAction(best, live); } /// /// Sticky-safe battle ranking: BattleKeeper + chunk cluster at . /// No CollectAllCombatFighters. /// private static InterestEvent BuildHottestBattleLight(Vector3 anchor) { InterestEvent best = RankBattleKeeper(allFighters: null); if (LiveEnsemble.TryBuildCombat(anchor, 14f, out LiveEnsemble local) && local != null && local.ParticipantCount >= 1) { InterestEvent localEvent = FromEnsembleBattle(local, anchor, deaths: 0); best = PreferRicherAction(best, localEvent); } return best; } private static InterestEvent RankBattleKeeper(List allFighters) { HashSet battles = BattleKeeperManager.get(); if (battles == null || battles.Count == 0) { return null; } InterestEvent best = null; float bestScore = float.MinValue; var nearby = allFighters != null ? new List(32) : null; foreach (BattleContainer battle in battles) { if (battle?.tile == null) { continue; } int deaths = battle.getDeathsTotal(); Vector3 pos = battle.tile.posV3; LiveEnsemble ensemble; if (allFighters != null) { nearby.Clear(); LiveEnsemble.CollectCombatFightersNear(allFighters, pos, 14f, nearby); ensemble = LiveEnsemble.FromCombatFighters(nearby, pos); } else if (!LiveEnsemble.TryBuildCombat(pos, 14f, out ensemble)) { ensemble = null; } 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) { continue; } bestScore = score; best = FromEnsembleBattle(ensemble, pos, deaths, follow, fighters, notables, score); } return best; } private static InterestEvent FromEnsembleBattle( LiveEnsemble ensemble, Vector3 pos, int deaths, Actor follow = null, int fighters = -1, int notables = -1, float score = -1f) { if (ensemble != null) { if (fighters < 0) { fighters = ensemble.ParticipantCount; } if (notables < 0) { notables = ensemble.NotableParticipantCount; } if (follow == null) { follow = ensemble.Focus; } } fighters = Mathf.Max(0, fighters); notables = Mathf.Max(0, notables); if (score < 0f) { score = ScoreFightCluster(deaths, fighters, notables); } Actor focus = follow ?? FindNearestAliveUnit(pos, 14f); string label = ensemble != null && ensemble.HasFocus ? EventReason.Combat(ensemble) : EventReason.Battle(focus, fighters); return 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 }; } /// /// Cluster of units currently in combat (even before BattleKeeper records deaths). /// One pass over a precollected fighter list - no per-seed world scans. /// private static InterestEvent BuildHottestFightCluster(List allFighters) { if (allFighters == null || allFighters.Count == 0) { return null; } InterestEvent best = null; float bestScore = float.MinValue; var claimed = new HashSet(); var cluster = new List(32); const float clusterRadius = 10f; for (int i = 0; i < allFighters.Count; i++) { Actor actor = allFighters[i]; if (actor == null || !actor.isAlive() || !actor.has_attack_target) { continue; } long id = EventFeedUtil.SafeId(actor); if (id == 0 || claimed.Contains(id)) { continue; } Vector2 seedPos = actor.current_position; cluster.Clear(); LiveEnsemble.CollectCombatFightersNear(allFighters, seedPos, clusterRadius, cluster); if (cluster.Count < 1) { continue; } for (int c = 0; c < cluster.Count; c++) { long cid = EventFeedUtil.SafeId(cluster[c]); if (cid != 0) { claimed.Add(cid); } } LiveEnsemble ensemble = LiveEnsemble.FromCombatFighters(cluster, seedPos); 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; } 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; } // Spectacle creatures (evil mage, dragon, …) win theater-lead picks in mob fights. // Use a dedicated floor so char-only weights among civs cannot outrank them. if (IsSpectacle(actor)) { ScoringWeights w = InterestScoringConfig.W; float spectacle = w.scannerSpectacleBonus > 0f ? w.scannerSpectacleBonus : 12f; weight += Mathf.Max(35f, spectacle); } 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 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; } // Prefer spectacles over civ ambient. Idle/warm chores never beat a live mage/dragon; // Hot combatants still need the prefer margin (spectacles should not erase every scrap). if (!IsSpectacle(best)) { Actor spectacle = null; float spectacleScore = float.MinValue; for (int i = 0; i < alive.Count; i++) { Actor actor = alive[i]; if (!IsSpectacle(actor)) { continue; } float unitScore = ScoreActor(actor, speciesCounts); if (unitScore > spectacleScore) { spectacleScore = unitScore; spectacle = actor; } } if (spectacle != null) { bool bestHotFight = best.has_attack_target && ActivityInterestTable.Classify(best) == ActivityBand.Hot; float preferMargin = InterestScoringConfig.W.scannerSpectaclePreferMargin > 0f ? InterestScoringConfig.W.scannerSpectaclePreferMargin : 28f; if (!bestHotFight || spectacleScore + preferMargin >= bestScore) { best = spectacle; bestScore = spectacleScore; } } } 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() { float now = Time.unscaledTime; if (_speciesCountCache != null && now - _speciesCountCachedAt < SpeciesCountCacheSeconds) { return _speciesCountCache; } 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]++; } _speciesCountCache = speciesCounts; _speciesCountCachedAt = now; 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. float score = action + character * InterestScoringConfig.W.scannerRankCharWeight; // Character Ledger bias for ambient fill - prefer watched lives over strangers. long id = EventFeedUtil.SafeId(actor); float ledger = CharacterLedger.Heat(id); if (ledger > 0f) { score += ledger * Mathf.Max(1f, InterestScoringConfig.Story.ledgerWeight * 0.35f); } float causal = CausalHeat.Get(id); if (causal > 0f) { score += causal * 2f; } return score; } /// /// 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; } bool spectacle = IsSpectacle(actor); if (spectacle) { 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; } // Policy overlay (evil_mage / dragon / …) - same source as InterestScoring meta. float speciesBonus = EventCatalogConfig.SpeciesBonus(speciesId); if (speciesBonus > 0f) { characterScore += speciesBonus; } 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. // Spectacles keep full action (idle floor applied below). if (band == ActivityBand.Cold && !actor.has_attack_target && !spectacle) { actionScore *= w.scannerColdActionMul; characterScore *= w.scannerColdCharMul; } // Idle evil mages / dragons still beat warm chores for ambient fill. if (spectacle && band == ActivityBand.Cold && !actor.has_attack_target) { float floor = w.scannerSpectacleIdleFloor > 0f ? w.scannerSpectacleIdleFloor : 42f; actionScore = Mathf.Max(actionScore, floor); } 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; } return IsSpectacleAssetId(actor.asset.id); } /// /// Live spectacle actor / power id class (mages, dragons, demons, …). /// Seed + token heuristics + live actor_library discovery. /// public static bool IsSpectacleAssetId(string assetId) { if (string.IsNullOrEmpty(assetId)) { return false; } string id = assetId.Trim().ToLowerInvariant(); if (id.StartsWith("boat_", StringComparison.Ordinal)) { return false; } HashSet live = EnsureSpectacleAssetIds(); if (live.Contains(id)) { return true; } return MatchesSpectacleToken(id); } private static HashSet EnsureSpectacleAssetIds() { float now = Time.unscaledTime; if (_spectacleAssetIds != null && now - _spectacleIdsCachedAt < SpectacleIdsCacheSeconds) { return _spectacleAssetIds; } var set = new HashSet(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < SpectacleAssetSeed.Length; i++) { set.Add(SpectacleAssetSeed[i]); } List liveActors = ActivityAssetCatalog.EnumerateLiveActorAssetIds(); for (int i = 0; i < liveActors.Count; i++) { string id = liveActors[i]; if (string.IsNullOrEmpty(id) || id.StartsWith("boat_", StringComparison.OrdinalIgnoreCase)) { continue; } if (MatchesSpectacleToken(id) || set.Contains(id)) { set.Add(id); } } _spectacleAssetIds = set; _spectacleIdsCachedAt = now; return _spectacleAssetIds; } private static bool MatchesSpectacleToken(string id) { if (string.IsNullOrEmpty(id)) { return false; } string s = id.ToLowerInvariant(); for (int i = 0; i < SpectacleIdTokens.Length; i++) { if (s.IndexOf(SpectacleIdTokens[i], StringComparison.Ordinal) >= 0) { return true; } } for (int i = 0; i < SpectacleAssetSeed.Length; i++) { if (s.IndexOf(SpectacleAssetSeed[i], StringComparison.OrdinalIgnoreCase) >= 0) { return true; } } return false; } /// Best living spectacle unit for ambient ranking (mages, dragons, …). private static InterestEvent BuildBestSpectacleUnit() { Actor best = null; float bestScore = float.MinValue; Dictionary counts = CountSpeciesPopulations(); foreach (Actor actor in EnumerateAliveUnits()) { if (actor == null || !actor.isAlive() || !IsSpectacle(actor)) { continue; } if (actor.asset != null && actor.asset.is_boat) { continue; } float score = ScoreActor(actor, counts); if (score > bestScore) { bestScore = score; best = actor; } } if (best == null) { return null; } SplitActorScore(best, counts, out float actionScore, out float charScore); float rankChar = InterestScoringConfig.W.scannerRankCharWeight; return new InterestEvent { Score = actionScore + charScore * rankChar, Position = best.current_position, FollowUnit = best, Label = "", CreatedAt = Time.unscaledTime, AssetId = best.asset != null ? best.asset.id : "spectacle", ParticipantCount = 0, NotableParticipantCount = 0, CharacterSignificance = charScore }; } 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 }; } }