worldbox-observer-mod/IdleSpectator/InterestScoring.cs

479 lines
12 KiB
C#

using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Cached enrichment for top-K finalists only.</summary>
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;
}
/// <summary>
/// Score breakdown and typed scene classifiers (combat / vignette). Ranking is TotalScore only.
/// </summary>
public static class InterestScoring
{
private static readonly Dictionary<long, InterestMetadataSnapshot> MetaCache =
new Dictionary<long, InterestMetadataSnapshot>(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<InterestCandidate> 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);
sig += SpeciesRarityBonus(snap.SpeciesId, w);
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<string, int> 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);
}
/// <summary>Legacy alias; score-only ranking.</summary>
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;
}
/// <summary>King, favorite, or very high renown/kills - can make a 1v1 outrank a no-name melee.</summary>
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;
}
}
/// <summary>True when this candidate is a live fight / battle cluster (not celebrity vignette).</summary>
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;
}
/// <summary>Celebrity / role vignette without a live action beat.</summary>
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<bool> fn)
{
try
{
return fn();
}
catch
{
return false;
}
}
private static float SafeFloat(System.Func<float> 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;
}
}
}