worldbox-observer-mod/IdleSpectator/InterestScoring.cs
DazedAnon 5cdea1930a Enhance combat handling and introduce frequency-based scoring adjustments.
- Implement new commands for marking combat cold and pushing interest arcs
- Update InterestDirector to manage forced cold combat states for peers
- Introduce frequency adjustments for scoring based on recent arc representation
- Refactor scoring weights to include combat urgency and frequency penalties
- Increment version to 0.28.38 in mod.json and scoring-model.json
2026-07-17 19:27:07 -05:00

564 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// 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);
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
c.TotalScore = c.EventStrength + scaleBonus
+ c.CharacterSignificance * charWeight
+ c.VisualConfidence * w.visualMultiplier
+ c.Novelty * w.noveltyMultiplier
- repeatPenalty
+ frequencyAdjust;
string detail =
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
if (repeatPenalty > 0.05f)
{
detail += $" rpt=-{repeatPenalty:0.#}";
}
if (Mathf.Abs(frequencyAdjust) > 0.05f)
{
detail += " freq="
+ (frequencyAdjust >= 0f ? "+" : "")
+ frequencyAdjust.ToString("0.#");
}
c.ScoreDetail = detail;
}
/// <summary>
/// Contested size: 2× minority side when both camps exist (38v1 → 2; 6v6 → 12).
/// </summary>
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);
}
/// <summary>
/// 1 when sides are balanced; approaches <see cref="ScoringWeights.fightBalanceLopsidedFloor"/> when lopsided.
/// </summary>
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<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
|| 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;
}
/// <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;
}
}
}