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; // Crowd bonuses are for combat/war/plot theaters - not soft pack/outbreak clusters. bool crowdScale = c.Completion == InterestCompletionKind.CombatActive || c.Completion == InterestCompletionKind.WarFront || c.Completion == InterestCompletionKind.PlotActive || c.AssetId == "live_battle" || string.Equals(c.Category, "Combat", System.StringComparison.OrdinalIgnoreCase); if (crowdScale) { int sideA = Mathf.Max(0, c.CombatSideACount); int sideB = Mathf.Max(0, c.CombatSideBCount); int effectiveFighters = ContestedFighters(fighters, sideA, sideB); float balanceMul = FightBalanceMultiplier(sideA, sideB, w); if (effectiveFighters >= w.massFighterThreshold) { scaleBonus += (w.massBaseBonus + Mathf.Min( w.massExtraCap, (effectiveFighters - w.massFighterThreshold) * w.massPerExtraFighter)) * balanceMul; } else if (effectiveFighters > 0 && effectiveFighters <= w.duelMaxFighters) { if (notables >= 2) { scaleBonus += w.duelNotablePairBonus; } else if (notables == 1) { scaleBonus += w.duelSingleNotableBonus; } else { scaleBonus += w.duelAnonymousPenalty; } } if (notables > 0 && effectiveFighters >= w.skirmishMinFighters) { scaleBonus += Mathf.Min(w.notableSkirmishCap, notables * w.notableSkirmishPer) * balanceMul; } if (c.Completion == InterestCompletionKind.WarFront && sideA >= 2 && sideB >= 2 && w.warFrontBalanceBonus > 0f) { scaleBonus += w.warFrontBalanceBonus * balanceMul; } } float repeatPenalty = InterestVariety.RepeatArcPenalty(c); c.TotalScore = c.EventStrength + scaleBonus + c.CharacterSignificance * charWeight + c.VisualConfidence * w.visualMultiplier + c.Novelty * w.noveltyMultiplier - repeatPenalty; c.ScoreDetail = $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}" + (repeatPenalty > 0.05f ? $" rpt=-{repeatPenalty:0.#}" : ""); } /// /// Contested size: 2× minority side when both camps exist (38v1 → 2; 6v6 → 12). /// public static int ContestedFighters(int fighters, int sideA, int sideB) { if (sideA >= 1 && sideB >= 1) { return Mathf.Max(2, 2 * Mathf.Min(sideA, sideB)); } return Mathf.Max(0, fighters); } /// /// 1 when sides are balanced; approaches when lopsided. /// public static float FightBalanceMultiplier(int sideA, int sideB, ScoringWeights w) { if (w == null) { w = InterestScoringConfig.W; } if (sideA < 1 || sideB < 1) { return 1f; } float balance = (float)Mathf.Min(sideA, sideB) / Mathf.Max(sideA, sideB); float fullAt = w.fightBalanceFullAt > 0.05f ? w.fightBalanceFullAt : 0.55f; float floor = Mathf.Clamp01(w.fightBalanceLopsidedFloor); if (balance >= fullAt) { return 1f; } float t = Mathf.Clamp01(balance / fullAt); return Mathf.Lerp(floor, 1f, t); } 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 += EventCatalogConfig.CharacterBonus("favorite", w.metaFavorite); } if (snap.King) { sig += EventCatalogConfig.CharacterBonus("king", w.metaKing); } else if (snap.Leader) { sig += EventCatalogConfig.CharacterBonus("leader", 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); sig += SpeciesRarityBonus(snap.SpeciesId, w); sig += EventCatalogConfig.SpeciesBonus(snap.SpeciesId); snap.CharacterSignificance = sig; MetaCache[id] = snap; return snap; } private static float SpeciesRarityBonus(string speciesId, ScoringWeights w) { if (string.IsNullOrEmpty(speciesId) || w == null) { return 0f; } try { Dictionary counts = WorldActivityScanner.CountSpeciesPopulations(); if (counts == null || !counts.TryGetValue(speciesId, out int pop) || pop <= 0) { return 0f; } if (pop == 1) { return w.speciesSingletonBonus; } int rareMax = w.speciesRareMaxPop > 0 ? w.speciesRareMaxPop : 5; if (pop <= rareMax) { return w.speciesRareBonus; } } catch { // ignore } return 0f; } 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 || c.Completion == InterestCompletionKind.WarFront || c.Completion == InterestCompletionKind.PlotActive) { 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; } } }