- 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
578 lines
16 KiB
C#
578 lines
16 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;
|
||
// 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;
|
||
}
|
||
}
|
||
}
|