worldbox-observer-mod/IdleSpectator/InterestScoring.cs
DazedAnon 739cc6492c feat(spectator): make narrative threads own camera direction
- Route confirmed life events into evidence-based threads, consequences, and Legacy chapters
- Schedule episode shots with critical interrupts, replay guards, and a combat budget
- Admit emerging main characters through story momentum instead of spectacle
- Remove causal heat, hard-arc memory writes, and synthetic epilogue continuity
- Expand narrative harness coverage, soak auditing, and product documentation
2026-07-23 12:17:18 -05:00

578 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);
float sagaBonus = LifeSagaRoster.ScoreBonus(c);
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
c.TotalScore = c.EventStrength + scaleBonus
+ c.CharacterSignificance * charWeight
+ c.VisualConfidence * w.visualMultiplier
+ c.Novelty * w.noveltyMultiplier
- repeatPenalty
+ frequencyAdjust
+ sagaBonus
+ ownershipBonus;
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.#");
}
if (sagaBonus > 0.05f)
{
detail += $" saga=+{sagaBonus:0.#}";
}
if (ownershipBonus > 0.05f)
{
detail += $" arc=+{ownershipBonus: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;
}
}
}