using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// Cached enrichment for top-K finalists only. public sealed class InterestMetadataSnapshot { public long UnitId; public float CharacterSignificance; public bool Favorite; public bool King; public bool Leader; public int Kills; public int Level; public float Renown; public string SpeciesId = ""; public string CityKey = ""; public string KingdomKey = ""; public ActivityBand Band = ActivityBand.Cold; public float CapturedAt; } /// /// Score breakdown and typed scene classifiers (combat / vignette). Ranking is TotalScore only. /// public static class InterestScoring { private static readonly Dictionary MetaCache = new Dictionary(64); private static float MetaCacheTtl => InterestScoringConfig.W.metaCacheTtl; private static int EnrichTopKLimit => InterestScoringConfig.W.enrichTopK; public static void ClearCache() { MetaCache.Clear(); } public static float EventStrengthForHappiness(string effectId) { return EventCatalog.Happiness.GetOrFallback(effectId).EventStrength; } public static float EventStrengthForWorldLog(string assetId) { return EventCatalog.WorldLog.GetOrFallback(assetId).EventStrength; } public static bool IsFillScore(float totalScore) { return totalScore < InterestScoringConfig.W.fillScoreMax; } public static bool IsHotScore(float totalScore) { return totalScore >= InterestScoringConfig.W.hotScoreMin; } public static void EnrichTopK(List candidates) { if (candidates == null || candidates.Count == 0) { return; } candidates.Sort(CompareByScore); int n = Mathf.Min(EnrichTopKLimit, candidates.Count); float now = Time.unscaledTime; for (int i = 0; i < n; i++) { InterestCandidate c = candidates[i]; if (c?.FollowUnit == null) { continue; } InterestMetadataSnapshot snap = GetOrBuildMeta(c.FollowUnit, now); ApplyMeta(c, snap); RecalcTotal(c); } } public static void ScoreCheap(InterestCandidate c) { if (c == null) { return; } RecalcTotal(c); } public static void RecalcTotal(InterestCandidate c) { if (c == null) { return; } // Action-primary: event strength dominates. Character is a tie-break / small amplifier. ScoringWeights w = InterestScoringConfig.W; float charWeight = c.LeadKind == InterestLeadKind.EventLed ? w.charWeightEventLed : w.charWeightCharacterLed; float scaleBonus = 0f; int fighters = c.ParticipantCount; int notables = c.NotableParticipantCount; if (fighters >= w.massFighterThreshold) { scaleBonus += w.massBaseBonus + Mathf.Min(w.massExtraCap, (fighters - w.massFighterThreshold) * w.massPerExtraFighter); } else if (fighters > 0 && fighters <= w.duelMaxFighters) { if (notables >= 2) { scaleBonus += w.duelNotablePairBonus; } else if (notables == 1) { scaleBonus += w.duelSingleNotableBonus; } else { scaleBonus += w.duelAnonymousPenalty; } } if (notables > 0 && fighters >= w.skirmishMinFighters) { scaleBonus += Mathf.Min(w.notableSkirmishCap, notables * w.notableSkirmishPer); } c.TotalScore = c.EventStrength + scaleBonus + c.CharacterSignificance * charWeight + c.VisualConfidence * w.visualMultiplier + c.Novelty * w.noveltyMultiplier; c.ScoreDetail = $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}"; } public static InterestMetadataSnapshot GetOrBuildMeta(Actor actor, float now) { if (actor == null) { return null; } long id = 0; try { id = actor.getID(); } catch { return null; } if (MetaCache.TryGetValue(id, out InterestMetadataSnapshot cached) && now - cached.CapturedAt < MetaCacheTtl) { return cached; } var snap = new InterestMetadataSnapshot { UnitId = id, CapturedAt = now, Favorite = SafeBool(() => actor.isFavorite()), King = SafeBool(() => actor.isKing()), Leader = SafeBool(() => actor.isCityLeader()), Kills = actor.data != null ? actor.data.kills : 0, Level = SafeLevel(actor), Renown = SafeFloat(() => actor.renown), SpeciesId = actor.asset != null ? actor.asset.id : "", CityKey = actor.city != null ? (actor.city.name ?? "") : "", KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "", Band = ActivityInterestTable.Classify(actor) }; ScoringWeights w = InterestScoringConfig.W; float sig = 0f; if (snap.Favorite) { sig += w.metaFavorite; } if (snap.King) { sig += w.metaKing; } else if (snap.Leader) { sig += w.metaLeader; } float renownDiv = w.metaRenownDivisor > 0f ? w.metaRenownDivisor : 120f; sig += Mathf.Min(w.metaRenownCap, snap.Renown / renownDiv); sig += Mathf.Min(w.metaKillsCap, snap.Kills * w.metaKillsFactor); sig += Mathf.Min(w.metaLevelCap, snap.Level * w.metaLevelFactor); snap.CharacterSignificance = sig; MetaCache[id] = snap; return snap; } private static void ApplyMeta(InterestCandidate c, InterestMetadataSnapshot snap) { if (c == null || snap == null) { return; } c.CharacterSignificance = Mathf.Max(c.CharacterSignificance, snap.CharacterSignificance); if (string.IsNullOrEmpty(c.SpeciesId)) { c.SpeciesId = snap.SpeciesId; } if (string.IsNullOrEmpty(c.CityKey)) { c.CityKey = snap.CityKey; } if (string.IsNullOrEmpty(c.KingdomKey)) { c.KingdomKey = snap.KingdomKey; } if (snap.Band >= ActivityBand.Warm) { c.VisualConfidence = Mathf.Max(c.VisualConfidence, 0.65f); } if (snap.Band == ActivityBand.Hot) { c.VisualConfidence = Mathf.Max(c.VisualConfidence, 0.85f); } } public static int CompareByScore(InterestCandidate a, InterestCandidate b) { if (ReferenceEquals(a, b)) { return 0; } if (a == null) { return 1; } if (b == null) { return -1; } int score = b.TotalScore.CompareTo(a.TotalScore); if (score != 0) { return score; } return b.LastSeenAt.CompareTo(a.LastSeenAt); } /// Legacy alias; score-only ranking. public static int CompareByUrgencyThenScore(InterestCandidate a, InterestCandidate b) { return CompareByScore(a, b); } public static bool IsNotable(Actor actor) { if (actor == null) { return false; } try { if (actor.isFavorite() || actor.isKing() || actor.isCityLeader()) { return true; } } catch { // ignore } try { int kills = actor.data != null ? actor.data.kills : 0; ScoringWeights w = InterestScoringConfig.W; if (kills >= w.notableKills || actor.renown >= w.notableRenown) { return true; } } catch { // ignore } return false; } /// King, favorite, or very high renown/kills - can make a 1v1 outrank a no-name melee. public static bool IsExtremelyNotable(Actor actor) { if (actor == null) { return false; } try { if (actor.isFavorite() || actor.isKing()) { return true; } } catch { // ignore } try { int kills = actor.data != null ? actor.data.kills : 0; ScoringWeights w = InterestScoringConfig.W; return kills >= w.extremeNotableKills || actor.renown >= w.extremeNotableRenown; } catch { return false; } } /// True when this candidate is a live fight / battle cluster (not celebrity vignette). public static bool IsCombatAction(InterestCandidate c) { if (c == null) { return false; } if (c.AssetId == "live_battle" || c.Category == "Combat") { return true; } if (!string.IsNullOrEmpty(c.Verb) && c.Verb.IndexOf("fight", System.StringComparison.OrdinalIgnoreCase) >= 0) { return true; } if (c.Completion == InterestCompletionKind.CombatActive) { return true; } if (c.FollowUnit != null) { try { if (c.FollowUnit.has_attack_target) { return true; } } catch { // ignore } } return false; } /// Celebrity / role vignette without a live action beat. public static bool IsCharacterVignette(InterestCandidate c) { if (c == null || IsCombatAction(c)) { return false; } if (c.LeadKind == InterestLeadKind.CharacterLed || c.Category == "CharacterVignette" || c.Completion == InterestCompletionKind.CharacterVignette) { return true; } // Scanner/harness celebrity strolls without a live action beat - combat may cut them. if ((c.Source == "scanner" || c.Source == "harness") && c.LeadKind == InterestLeadKind.CharacterLed && string.IsNullOrEmpty(c.HappinessEffectId)) { return true; } return false; } private static bool SafeBool(System.Func fn) { try { return fn(); } catch { return false; } } private static float SafeFloat(System.Func fn) { try { return fn(); } catch { return 0f; } } private static int SafeLevel(Actor actor) { try { return actor.data != null ? actor.data.level : 0; } catch { return 0; } } }