491 lines
13 KiB
C#
491 lines
13 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>
|
|
/// Urgency-class mapping and within-class score breakdown.
|
|
/// </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 InterestTier UrgencyForHappiness(string effectId, Actor subject)
|
|
{
|
|
string id = effectId ?? "";
|
|
bool notable = IsNotable(subject);
|
|
|
|
if (id == "death_child" || id == "death_lover")
|
|
{
|
|
return notable ? InterestTier.Story : InterestTier.Action;
|
|
}
|
|
|
|
if (id == "death_best_friend" || id == "death_family_member")
|
|
{
|
|
return InterestTier.Action;
|
|
}
|
|
|
|
if (id == "got_saved" || id == "just_born" || id == "just_hatched"
|
|
|| id == "become_king" || id == "become_city_leader" || id == "become_clan_chief"
|
|
|| id == "just_found_house" || id == "new_home")
|
|
{
|
|
return InterestTier.Action;
|
|
}
|
|
|
|
return InterestTier.Curiosity;
|
|
}
|
|
|
|
public static float EventStrengthForHappiness(string effectId)
|
|
{
|
|
string id = effectId ?? "";
|
|
switch (id)
|
|
{
|
|
case "death_child":
|
|
return 95f;
|
|
case "death_lover":
|
|
return 88f;
|
|
case "death_best_friend":
|
|
return 78f;
|
|
case "death_family_member":
|
|
return 76f;
|
|
case "just_born":
|
|
case "just_hatched":
|
|
case "had_child":
|
|
case "just_had_child":
|
|
return 70f;
|
|
case "become_king":
|
|
case "become_city_leader":
|
|
case "become_clan_chief":
|
|
return 72f;
|
|
case "just_found_house":
|
|
case "new_home":
|
|
return 55f;
|
|
default:
|
|
return 40f;
|
|
}
|
|
}
|
|
|
|
public static InterestTier UrgencyForWorldLog(string assetId)
|
|
{
|
|
return WorldLogInterestTable.TryGetTier(assetId, out InterestTier tier)
|
|
? tier
|
|
: InterestTier.Ambient;
|
|
}
|
|
|
|
public static void EnrichTopK(List<InterestCandidate> candidates)
|
|
{
|
|
if (candidates == null || candidates.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
candidates.Sort(CompareByUrgencyThenScore);
|
|
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.
|
|
// Scale / notables on clusters (battles) add to event strength before this runs.
|
|
// Knobs: IdleSpectator/scoring-model.json → weights (InterestScoringConfig.W).
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
float charWeight = c.LeadKind == InterestLeadKind.EventLed
|
|
? w.charWeightEventLed
|
|
: w.charWeightCharacterLed;
|
|
float urgencyWeight = 1f + (int)c.Urgency * w.urgencyTierStep;
|
|
float scaleBonus = 0f;
|
|
int fighters = c.ParticipantCount;
|
|
int notables = c.NotableParticipantCount;
|
|
if (fighters >= w.massFighterThreshold)
|
|
{
|
|
// Multi-person melees outrank thin anonymous 1v1s.
|
|
scaleBonus += w.massBaseBonus
|
|
+ Mathf.Min(w.massExtraCap, (fighters - w.massFighterThreshold) * w.massPerExtraFighter);
|
|
}
|
|
else if (fighters > 0 && fighters <= w.duelMaxFighters)
|
|
{
|
|
if (notables >= 2)
|
|
{
|
|
// Extremely important duel (kings/favorites) can beat nameless melees.
|
|
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) * urgencyWeight
|
|
+ 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);
|
|
snap.CharacterSignificance = sig;
|
|
MetaCache[id] = snap;
|
|
return snap;
|
|
}
|
|
|
|
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 CompareByUrgencyThenScore(InterestCandidate a, InterestCandidate b)
|
|
{
|
|
if (ReferenceEquals(a, b))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (a == null)
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
if (b == null)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
int urg = ((int)b.Urgency).CompareTo((int)a.Urgency);
|
|
if (urg != 0)
|
|
{
|
|
return urg;
|
|
}
|
|
|
|
int score = b.TotalScore.CompareTo(a.TotalScore);
|
|
if (score != 0)
|
|
{
|
|
return score;
|
|
}
|
|
|
|
return b.LastSeenAt.CompareTo(a.LastSeenAt);
|
|
}
|
|
|
|
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 "Story" celebrity strolls are not WorldLog events - actions may cut them.
|
|
if (c.Urgency == InterestTier.Story
|
|
&& (c.Source == "scanner" || c.Source == "harness")
|
|
&& 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;
|
|
}
|
|
}
|
|
}
|