1034 lines
31 KiB
C#
1034 lines
31 KiB
C#
using System.Collections.Generic;
|
|
using NeoModLoader.services;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Scores live world activity across civs and wild mobs (wiki-aligned).
|
|
/// Throttled to ~2 Hz while Spectator is active.
|
|
/// </summary>
|
|
public static class WorldActivityScanner
|
|
{
|
|
private static readonly HashSet<string> SpectacleAssets = new HashSet<string>
|
|
{
|
|
"dragon",
|
|
"greg",
|
|
"demon",
|
|
"evil_mage",
|
|
"necromancer",
|
|
"ufo",
|
|
"cold_one",
|
|
"walker",
|
|
"snowman",
|
|
"bandit",
|
|
"tumor_monster",
|
|
"bioblob",
|
|
"assimilator",
|
|
"lil_pumpkin",
|
|
"fire_elemental",
|
|
"skeleton",
|
|
"zombie",
|
|
"ghost",
|
|
"mush"
|
|
};
|
|
|
|
private static readonly System.Random Rng = new System.Random();
|
|
private static float _lastScanAt = -999f;
|
|
private static InterestEvent _cachedBest;
|
|
private static InterestEvent _cachedBattle;
|
|
private const float ScanInterval = 0.5f;
|
|
private static float _lastEmptyLogAt = -999f;
|
|
|
|
public static InterestEvent FindBestLiveTarget()
|
|
{
|
|
EnsureScanned();
|
|
if (_cachedBest == null && Time.unscaledTime - _lastEmptyLogAt > 15f)
|
|
{
|
|
_lastEmptyLogAt = Time.unscaledTime;
|
|
LogService.LogInfo("[IdleSpectator] No live units to follow yet");
|
|
}
|
|
|
|
return Clone(_cachedBest);
|
|
}
|
|
|
|
public static InterestEvent FindHottestBattle()
|
|
{
|
|
EnsureScanned();
|
|
return Clone(_cachedBattle);
|
|
}
|
|
|
|
/// <summary>Register up to <paramref name="max"/> character-led vignette seeds into the registry.</summary>
|
|
public static void RegisterTopCharacterCandidates(int max = 4)
|
|
{
|
|
EnsureScanned();
|
|
Dictionary<string, int> speciesCounts = CountSpeciesPopulations();
|
|
List<Actor> alive = new List<Actor>(128);
|
|
foreach (Actor actor in EnumerateAliveUnits())
|
|
{
|
|
if (actor?.asset != null && actor.asset.is_boat)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (actor != null && actor.isAlive())
|
|
{
|
|
alive.Add(actor);
|
|
}
|
|
}
|
|
|
|
if (alive.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
alive.Sort((a, b) => ScoreActor(b, speciesCounts).CompareTo(ScoreActor(a, speciesCounts)));
|
|
int n = Mathf.Min(max, alive.Count);
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
Actor actor = alive[i];
|
|
ActivityBand band = ActivityInterestTable.Classify(actor);
|
|
InterestLeadKind lead = InterestLeadKind.CharacterLed;
|
|
// Actions outrank characters: combat/hot work are events; kings walking stay vignettes.
|
|
if (band == ActivityBand.Hot || actor.has_attack_target)
|
|
{
|
|
lead = InterestLeadKind.EventLed;
|
|
}
|
|
else if (band >= ActivityBand.Warm)
|
|
{
|
|
lead = InterestLeadKind.EventLed;
|
|
}
|
|
else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts))
|
|
{
|
|
lead = InterestLeadKind.CharacterLed;
|
|
}
|
|
|
|
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
|
|
long id = 0;
|
|
try
|
|
{
|
|
id = actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string key = lead == InterestLeadKind.CharacterLed
|
|
? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit")
|
|
: "scan:evt:" + id;
|
|
|
|
string label = FormatUnitLabel(actor, speciesCounts);
|
|
if (actor.has_attack_target)
|
|
{
|
|
label = "Fighting: " + SafeName(actor);
|
|
}
|
|
else if (band == ActivityBand.Hot)
|
|
{
|
|
label = "In action";
|
|
}
|
|
|
|
bool combat = actor.has_attack_target || band == ActivityBand.Hot;
|
|
int fighters = 1;
|
|
int notables = InterestScoring.IsNotable(actor) ? 1 : 0;
|
|
if (combat)
|
|
{
|
|
CountFightCluster(actor.current_position, 10f, out fighters, out notables, out _);
|
|
fighters = Mathf.Max(1, fighters);
|
|
}
|
|
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = key,
|
|
LeadKind = lead,
|
|
Category = combat
|
|
? "Combat"
|
|
: (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"),
|
|
Source = "scanner",
|
|
EventStrength = lead == InterestLeadKind.EventLed
|
|
? actionScore
|
|
: actionScore * InterestScoringConfig.W.characterLedEventMul,
|
|
CharacterSignificance = charScore,
|
|
VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f,
|
|
Position = actor.current_position,
|
|
FollowUnit = actor,
|
|
SubjectId = id,
|
|
Label = label,
|
|
Verb = actor.has_attack_target ? "fighting" : (band == ActivityBand.Hot ? "busy" : ""),
|
|
AssetId = actor.asset != null ? actor.asset.id : "scored_unit",
|
|
SpeciesId = actor.asset != null ? actor.asset.id : "",
|
|
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
|
|
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
|
|
ParticipantCount = combat ? fighters : 0,
|
|
NotableParticipantCount = combat ? notables : 0,
|
|
CreatedAt = Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + 18f,
|
|
MinWatch = 2f,
|
|
MaxWatch = 22f,
|
|
Completion = combat
|
|
? InterestCompletionKind.CombatActive
|
|
: (lead == InterestLeadKind.EventLed
|
|
? InterestCompletionKind.ActivityActive
|
|
: InterestCompletionKind.CharacterVignette)
|
|
};
|
|
InterestScoring.ScoreCheap(candidate);
|
|
EventFeedUtil.RegisterCandidate(candidate);
|
|
}
|
|
}
|
|
|
|
private static void EnsureScanned()
|
|
{
|
|
if (Time.unscaledTime - _lastScanAt < ScanInterval)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastScanAt = Time.unscaledTime;
|
|
ScanNow();
|
|
}
|
|
|
|
private static void ScanNow()
|
|
{
|
|
_cachedBattle = BuildHottestBattle();
|
|
InterestEvent bestUnit = BuildBestScoredUnit();
|
|
_cachedBest = PreferRicherAction(_cachedBattle, bestUnit);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pick battle vs unit dynamically: mass fights win unless a notable 1v1 outranks an anonymous melee.
|
|
/// </summary>
|
|
private static InterestEvent PreferRicherAction(InterestEvent battle, InterestEvent unit)
|
|
{
|
|
if (battle == null)
|
|
{
|
|
return unit;
|
|
}
|
|
|
|
if (unit == null)
|
|
{
|
|
return battle;
|
|
}
|
|
|
|
bool unitDuel = unit.FollowUnit != null
|
|
&& unit.FollowUnit.has_attack_target
|
|
&& unit.ParticipantCount > 0
|
|
&& unit.ParticipantCount <= InterestScoringConfig.W.duelMaxFighters
|
|
&& unit.NotableParticipantCount >= 2;
|
|
bool battleAnonymousMelee = battle.ParticipantCount >= InterestScoringConfig.W.massFighterThreshold
|
|
&& battle.NotableParticipantCount == 0;
|
|
|
|
if (unitDuel && battleAnonymousMelee
|
|
&& unit.Score + InterestScoringConfig.W.preferNotableDuelMargin >= battle.Score)
|
|
{
|
|
return unit;
|
|
}
|
|
|
|
return battle.Score >= unit.Score ? battle : unit;
|
|
}
|
|
|
|
private static InterestEvent BuildHottestBattle()
|
|
{
|
|
HashSet<BattleContainer> battles = BattleKeeperManager.get();
|
|
if (battles == null || battles.Count == 0)
|
|
{
|
|
// Fall through to live fight clusters with no BattleKeeper entry yet.
|
|
return BuildHottestFightCluster();
|
|
}
|
|
|
|
InterestEvent best = null;
|
|
float bestScore = float.MinValue;
|
|
foreach (BattleContainer battle in battles)
|
|
{
|
|
if (battle?.tile == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int deaths = battle.getDeathsTotal();
|
|
Vector3 pos = battle.tile.posV3;
|
|
CountFightCluster(pos, 14f, out int fighters, out int notables, out Actor follow);
|
|
if (deaths < 1 && fighters < 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
fighters = Mathf.Max(fighters, deaths > 0 ? 2 : 0);
|
|
float score = ScoreFightCluster(deaths, fighters, notables);
|
|
if (score > bestScore)
|
|
{
|
|
bestScore = score;
|
|
string label = fighters >= 5 || deaths >= 6
|
|
? $"Battle ({fighters} fighting, {deaths} fallen)"
|
|
: fighters <= 2
|
|
? $"Duel ({deaths} fallen)"
|
|
: $"Skirmish ({fighters} fighting)";
|
|
best = new InterestEvent
|
|
{
|
|
Score = score,
|
|
Position = pos,
|
|
FollowUnit = follow ?? FindNearestAliveUnit(pos, 14f),
|
|
Label = label,
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = "live_battle",
|
|
ParticipantCount = fighters,
|
|
NotableParticipantCount = notables,
|
|
CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable
|
|
};
|
|
}
|
|
}
|
|
|
|
InterestEvent live = BuildHottestFightCluster();
|
|
return PreferRicherAction(best, live);
|
|
}
|
|
|
|
/// <summary>Cluster of units currently in combat (even before BattleKeeper records deaths).</summary>
|
|
private static InterestEvent BuildHottestFightCluster()
|
|
{
|
|
InterestEvent best = null;
|
|
float bestScore = float.MinValue;
|
|
var seen = new HashSet<long>();
|
|
foreach (Actor actor in EnumerateAliveUnits())
|
|
{
|
|
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
long id = 0;
|
|
try
|
|
{
|
|
id = actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (seen.Contains(id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out Actor follow);
|
|
if (fighters < 1)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Mark cluster members so we don't emit N duplicates.
|
|
MarkClusterSeen(actor.current_position, 10f, seen);
|
|
float score = ScoreFightCluster(deaths: 0, fighters, notables);
|
|
if (score > bestScore)
|
|
{
|
|
bestScore = score;
|
|
best = new InterestEvent
|
|
{
|
|
Score = score,
|
|
Position = actor.current_position,
|
|
FollowUnit = follow ?? actor,
|
|
Label = fighters <= 2 ? "Duel" : $"Fight ({fighters})",
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = "live_battle",
|
|
ParticipantCount = fighters,
|
|
NotableParticipantCount = notables,
|
|
CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable
|
|
};
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
private static float ScoreFightCluster(int deaths, int fighters, int notables)
|
|
{
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
float score = deaths * w.clusterDeathWeight
|
|
+ Mathf.Max(0, fighters) * w.clusterFighterWeight
|
|
+ notables * w.clusterNotableWeight;
|
|
// Multi-person melees get a clear bump over thin 1v1s.
|
|
if (fighters >= w.massFighterThreshold)
|
|
{
|
|
score += w.clusterMassBonus;
|
|
}
|
|
else if (fighters <= w.duelMaxFighters)
|
|
{
|
|
score += w.clusterDuelPenalty;
|
|
// Notable duels recover - king vs king / favorites.
|
|
if (notables >= 2)
|
|
{
|
|
score += w.clusterNotableDuelBonus;
|
|
}
|
|
else if (notables == 1)
|
|
{
|
|
score += w.clusterSingleNotableBonus;
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
private static void CountFightCluster(
|
|
Vector2 pos,
|
|
float radius,
|
|
out int fighters,
|
|
out int notables,
|
|
out Actor bestFollow)
|
|
{
|
|
fighters = 0;
|
|
notables = 0;
|
|
bestFollow = null;
|
|
float bestChar = -1f;
|
|
float r2 = radius * radius;
|
|
foreach (Actor actor in EnumerateAliveUnits())
|
|
{
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float dx = actor.current_position.x - pos.x;
|
|
float dy = actor.current_position.y - pos.y;
|
|
if (dx * dx + dy * dy > r2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bool fighting = false;
|
|
try
|
|
{
|
|
fighting = actor.has_attack_target
|
|
|| (actor.hasTask() && actor.ai?.task != null && actor.ai.task.in_combat);
|
|
}
|
|
catch
|
|
{
|
|
fighting = false;
|
|
}
|
|
|
|
if (!fighting)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
fighters++;
|
|
bool notable = InterestScoring.IsNotable(actor);
|
|
bool extreme = InterestScoring.IsExtremelyNotable(actor);
|
|
if (notable)
|
|
{
|
|
notables++;
|
|
}
|
|
|
|
SplitActorScore(actor, null, out _, out float charScore);
|
|
float weight = charScore + (extreme ? 30f : 0f);
|
|
if (weight > bestChar)
|
|
{
|
|
bestChar = weight;
|
|
bestFollow = actor;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet<long> seen)
|
|
{
|
|
float r2 = radius * radius;
|
|
foreach (Actor actor in EnumerateAliveUnits())
|
|
{
|
|
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float dx = actor.current_position.x - pos.x;
|
|
float dy = actor.current_position.y - pos.y;
|
|
if (dx * dx + dy * dy > r2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
seen.Add(actor.getID());
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
private static InterestEvent BuildBestScoredUnit()
|
|
{
|
|
Dictionary<string, int> speciesCounts = new Dictionary<string, int>();
|
|
List<Actor> alive = new List<Actor>(128);
|
|
foreach (Actor actor in EnumerateAliveUnits())
|
|
{
|
|
if (actor.asset != null && actor.asset.is_boat)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
alive.Add(actor);
|
|
string speciesId = actor.asset != null ? actor.asset.id : "unknown";
|
|
speciesCounts.TryGetValue(speciesId, out int count);
|
|
speciesCounts[speciesId] = count + 1;
|
|
}
|
|
|
|
if (alive.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Actor best = null;
|
|
float bestScore = float.MinValue;
|
|
List<Actor> nearTies = new List<Actor>();
|
|
|
|
for (int i = 0; i < alive.Count; i++)
|
|
{
|
|
Actor actor = alive[i];
|
|
float unitScore = ScoreActor(actor, speciesCounts);
|
|
if (unitScore > bestScore + 0.01f)
|
|
{
|
|
bestScore = unitScore;
|
|
best = actor;
|
|
nearTies.Clear();
|
|
nearTies.Add(actor);
|
|
}
|
|
else if (Mathf.Abs(unitScore - bestScore) <= 2f)
|
|
{
|
|
nearTies.Add(actor);
|
|
}
|
|
}
|
|
|
|
if (nearTies.Count > 1)
|
|
{
|
|
best = nearTies[Rng.Next(nearTies.Count)];
|
|
bestScore = ScoreActor(best, speciesCounts);
|
|
}
|
|
|
|
if (best == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
ActivityBand band = ActivityInterestTable.Classify(best);
|
|
SplitActorScore(best, speciesCounts, out float actionScore, out float charScore);
|
|
int fighters = 0;
|
|
int notables = 0;
|
|
if (best.has_attack_target || band == ActivityBand.Hot)
|
|
{
|
|
CountFightCluster(best.current_position, 10f, out fighters, out notables, out _);
|
|
fighters = Mathf.Max(1, fighters);
|
|
}
|
|
|
|
float finalScore = ScoreFightCluster(0, fighters, notables);
|
|
float rankChar = InterestScoringConfig.W.scannerRankCharWeight;
|
|
if (fighters <= 0)
|
|
{
|
|
finalScore = actionScore + charScore * rankChar;
|
|
}
|
|
else
|
|
{
|
|
finalScore = Mathf.Max(finalScore, actionScore + charScore * rankChar);
|
|
}
|
|
|
|
return new InterestEvent
|
|
{
|
|
Score = finalScore,
|
|
Position = best.current_position,
|
|
FollowUnit = best,
|
|
Label = FormatUnitLabel(best, speciesCounts),
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = best.asset != null ? best.asset.id : "scored_unit",
|
|
ParticipantCount = fighters,
|
|
NotableParticipantCount = notables,
|
|
CharacterSignificance = charScore
|
|
};
|
|
}
|
|
|
|
/// <summary>Public wrappers for dossier / harness (same logic as private scorers).</summary>
|
|
public static Dictionary<string, int> CountSpeciesPopulations()
|
|
{
|
|
Dictionary<string, int> speciesCounts = new Dictionary<string, int>();
|
|
foreach (Actor actor in EnumerateAliveUnits())
|
|
{
|
|
if (actor?.asset == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string id = actor.asset.id;
|
|
if (!speciesCounts.ContainsKey(id))
|
|
{
|
|
speciesCounts[id] = 0;
|
|
}
|
|
|
|
speciesCounts[id]++;
|
|
}
|
|
|
|
return speciesCounts;
|
|
}
|
|
|
|
public static bool HarnessActivityFirstScoringOk(out string detail)
|
|
{
|
|
// Mirror ScoreActor importance gating with synthetic bands.
|
|
float Cold(float importance) =>
|
|
ComposeScore(4f, ActivityBand.Cold, importance);
|
|
float Warm(float importance) =>
|
|
ComposeScore(28f, ActivityBand.Warm, importance);
|
|
float Hot(float importance) =>
|
|
ComposeScore(55f, ActivityBand.Hot, importance);
|
|
|
|
float coldKingFav = Cold(40f); // favorite + king
|
|
float hotPeasant = Hot(0f);
|
|
float warmFav = Warm(22f);
|
|
float coldPeasant = Cold(0f);
|
|
|
|
bool ok = hotPeasant > coldKingFav
|
|
&& warmFav > coldKingFav
|
|
&& hotPeasant > warmFav
|
|
&& coldKingFav > coldPeasant;
|
|
detail =
|
|
$"coldKingFav={coldKingFav:0.0} hotPeasant={hotPeasant:0.0} warmFav={warmFav:0.0} coldPeasant={coldPeasant:0.0}";
|
|
return ok;
|
|
}
|
|
|
|
private static float ComposeScore(float activity, ActivityBand band, float importance)
|
|
{
|
|
if (band >= ActivityBand.Warm || activity >= ActivityInterestTable.WarmScoreFloor)
|
|
{
|
|
return activity + importance * 0.85f;
|
|
}
|
|
|
|
return activity + importance * 0.08f;
|
|
}
|
|
|
|
public static float ScoreActorPublic(Actor actor, Dictionary<string, int> speciesCounts)
|
|
{
|
|
return ScoreActor(actor, speciesCounts);
|
|
}
|
|
|
|
public static bool IsSpectaclePublic(Actor actor)
|
|
{
|
|
return IsSpectacle(actor);
|
|
}
|
|
|
|
public static bool IsSingletonSpeciesPublic(Actor actor, Dictionary<string, int> speciesCounts)
|
|
{
|
|
return IsSingletonSpecies(actor, speciesCounts);
|
|
}
|
|
|
|
public static string FormatUnitLabelPublic(Actor actor, Dictionary<string, int> speciesCounts)
|
|
{
|
|
return FormatUnitLabel(actor, speciesCounts);
|
|
}
|
|
|
|
private static float ScoreActor(Actor actor, Dictionary<string, int> speciesCounts)
|
|
{
|
|
SplitActorScore(actor, speciesCounts, out float action, out float character);
|
|
// Action-primary total used for ranking; character is a light tie-break.
|
|
return action + character * InterestScoringConfig.W.scannerRankCharWeight;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Split live action intensity from who the unit is.
|
|
/// Same action → higher character wins; character alone never dominates a real fight.
|
|
/// </summary>
|
|
private static void SplitActorScore(
|
|
Actor actor,
|
|
Dictionary<string, int> speciesCounts,
|
|
out float actionScore,
|
|
out float characterScore)
|
|
{
|
|
actionScore = 0f;
|
|
characterScore = 0f;
|
|
if (actor == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
float activity = ActivityInterestTable.ScoreActivity(actor);
|
|
ActivityBand band = ActivityInterestTable.Classify(actor);
|
|
actionScore = activity;
|
|
if (actor.has_attack_target)
|
|
{
|
|
actionScore += w.scannerAttackBonus;
|
|
}
|
|
|
|
if (band == ActivityBand.Hot)
|
|
{
|
|
actionScore += w.scannerHotBonus;
|
|
}
|
|
else if (band == ActivityBand.Warm)
|
|
{
|
|
actionScore += w.scannerWarmBonus;
|
|
}
|
|
|
|
if (IsSpectacle(actor))
|
|
{
|
|
actionScore += w.scannerSpectacleBonus;
|
|
}
|
|
|
|
int kills = actor.data != null ? actor.data.kills : 0;
|
|
if (band >= ActivityBand.Warm || actor.has_attack_target)
|
|
{
|
|
actionScore += Mathf.Min(w.scannerActionKillsCap, kills * w.scannerActionKillsFactor);
|
|
}
|
|
|
|
// Character significance - tie-break / amplifier only.
|
|
if (actor.isFavorite())
|
|
{
|
|
characterScore += w.metaFavorite;
|
|
}
|
|
|
|
if (actor.isKing())
|
|
{
|
|
characterScore += w.metaKing;
|
|
}
|
|
else if (actor.isCityLeader())
|
|
{
|
|
characterScore += w.metaLeader;
|
|
}
|
|
else if (actor.is_profession_warrior)
|
|
{
|
|
characterScore += w.scannerWarriorBonus;
|
|
}
|
|
|
|
string speciesId = actor.asset != null ? actor.asset.id : "unknown";
|
|
if (speciesCounts != null
|
|
&& speciesCounts.TryGetValue(speciesId, out int pop)
|
|
&& pop == 1)
|
|
{
|
|
characterScore += w.scannerSingletonBonus;
|
|
}
|
|
|
|
float renownDiv = w.scannerCharRenownDivisor > 0f ? w.scannerCharRenownDivisor : 120f;
|
|
characterScore += Mathf.Min(w.scannerCharRenownCap, actor.renown / renownDiv);
|
|
characterScore += Mathf.Min(w.scannerCharKillsCap, kills * w.scannerCharKillsFactor);
|
|
|
|
// Cold kings barely score - do not let identity monopolize the camera.
|
|
if (band == ActivityBand.Cold && !actor.has_attack_target && !IsSpectacle(actor))
|
|
{
|
|
actionScore *= w.scannerColdActionMul;
|
|
characterScore *= w.scannerColdCharMul;
|
|
}
|
|
|
|
actionScore += (actor.getID() % 7) * 0.01f;
|
|
}
|
|
|
|
/// <summary>True when the focused unit's current activity is Cold (idle scoring).</summary>
|
|
public static bool IsFocusActivityCold(Actor actor)
|
|
{
|
|
return ActivityInterestTable.Classify(actor) == ActivityBand.Cold
|
|
&& (actor == null || !actor.has_attack_target);
|
|
}
|
|
|
|
private static int CountSubspeciesMembers(Subspecies subspecies)
|
|
{
|
|
int count = 0;
|
|
// Prefer actor.asset.units filtered by subspecies when possible.
|
|
if (subspecies == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
foreach (Actor unit in EnumerateAliveUnits())
|
|
{
|
|
if (unit.subspecies == subspecies)
|
|
{
|
|
count++;
|
|
if (count > 8)
|
|
{
|
|
return count;
|
|
}
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
private static bool IsSpectacle(Actor actor)
|
|
{
|
|
if (actor?.asset == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string id = actor.asset.id;
|
|
if (SpectacleAssets.Contains(id))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Loose match for variant ids.
|
|
foreach (string key in SpectacleAssets)
|
|
{
|
|
if (id.Contains(key))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsSingletonSpecies(Actor actor, Dictionary<string, int> speciesCounts)
|
|
{
|
|
if (actor?.asset == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return speciesCounts.TryGetValue(actor.asset.id, out int pop) && pop == 1;
|
|
}
|
|
|
|
private static string FormatUnitLabel(Actor actor, Dictionary<string, int> speciesCounts)
|
|
{
|
|
string name = SafeName(actor);
|
|
string species = actor.asset != null ? actor.asset.id : "creature";
|
|
|
|
// Event / activity first - titles alone are not a watch reason (nametag already has the name).
|
|
if (actor.has_attack_target)
|
|
{
|
|
return $"Fighting: {name}";
|
|
}
|
|
|
|
if (IsSpectacle(actor))
|
|
{
|
|
return $"Spectacle: {name} ({species})";
|
|
}
|
|
|
|
if (actor.isFavorite())
|
|
{
|
|
return $"Favorite: {name}";
|
|
}
|
|
|
|
if (actor.isKing())
|
|
{
|
|
string kingdom = actor.kingdom != null ? actor.kingdom.name : "realm";
|
|
return $"King of {kingdom}";
|
|
}
|
|
|
|
if (actor.isCityLeader())
|
|
{
|
|
string city = actor.city != null ? actor.city.name : "town";
|
|
return $"Leader of {city}";
|
|
}
|
|
|
|
if (IsSingletonSpecies(actor, speciesCounts))
|
|
{
|
|
return $"Lone {species}: {name}";
|
|
}
|
|
|
|
int kills = actor.data != null ? actor.data.kills : 0;
|
|
if (kills > 0)
|
|
{
|
|
return $"{name} ({species}, {kills} kills)";
|
|
}
|
|
|
|
return $"{name} ({species})";
|
|
}
|
|
|
|
public static InterestEvent FromNewSpecies(Subspecies subspecies, ActorAsset asset, WorldTile tile)
|
|
{
|
|
if (asset == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Vector3 pos = tile != null ? tile.posV3 : Vector3.zero;
|
|
Actor follow = FindMatchingSpeciesUnit(asset, subspecies, pos, 30f);
|
|
if (follow != null)
|
|
{
|
|
pos = follow.current_position;
|
|
}
|
|
|
|
// No matching unit yet = ghost tip (wrong creature gets focused). Skip until one exists.
|
|
if (follow == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new InterestEvent
|
|
{
|
|
Score = 5f,
|
|
Position = pos,
|
|
FollowUnit = follow,
|
|
Label = $"New species: {asset.id}",
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = asset.id
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nearest living unit, optionally restricted to a specific actor asset id.
|
|
/// </summary>
|
|
public static Actor FindNearestAliveUnit(Vector3 pos, float maxDist, string requireAssetId = null)
|
|
{
|
|
// Freshly spawned units are often on asset.units before units_only_alive updates.
|
|
if (!string.IsNullOrEmpty(requireAssetId))
|
|
{
|
|
Actor fromAsset = FindAliveOnAssetList(requireAssetId, pos, maxDist);
|
|
if (fromAsset != null)
|
|
{
|
|
return fromAsset;
|
|
}
|
|
}
|
|
|
|
Actor best = null;
|
|
float bestDist = maxDist * maxDist;
|
|
foreach (Actor actor in EnumerateAliveUnits())
|
|
{
|
|
if (requireAssetId != null
|
|
&& (actor.asset == null || actor.asset.id != requireAssetId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float dx = actor.current_position.x - pos.x;
|
|
float dy = actor.current_position.y - pos.y;
|
|
float d2 = dx * dx + dy * dy;
|
|
if (d2 < bestDist)
|
|
{
|
|
bestDist = d2;
|
|
best = actor;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Any living unit of this asset id (asset.units first, then world scan).
|
|
/// </summary>
|
|
public static Actor FindAliveByAssetId(string assetId, Vector3 nearPos, float maxDist = 5000f)
|
|
{
|
|
if (string.IsNullOrEmpty(assetId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return FindNearestAliveUnit(nearPos, maxDist, assetId);
|
|
}
|
|
|
|
private static Actor FindAliveOnAssetList(string assetId, Vector3 nearPos, float maxDist)
|
|
{
|
|
ActorAsset asset = AssetManager.actor_library?.get(assetId);
|
|
if (asset?.units == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Actor best = null;
|
|
float bestDist = maxDist * maxDist;
|
|
foreach (Actor unit in asset.units)
|
|
{
|
|
if (unit == null || !unit.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float dx = unit.current_position.x - nearPos.x;
|
|
float dy = unit.current_position.y - nearPos.y;
|
|
float d2 = dx * dx + dy * dy;
|
|
if (d2 < bestDist)
|
|
{
|
|
bestDist = d2;
|
|
best = unit;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
private static Actor FindMatchingSpeciesUnit(ActorAsset asset, Subspecies subspecies, Vector3 nearPos, float maxDist)
|
|
{
|
|
if (asset?.units != null)
|
|
{
|
|
foreach (Actor unit in asset.units)
|
|
{
|
|
if (unit != null && unit.isAlive() && (subspecies == null || unit.subspecies == subspecies))
|
|
{
|
|
return unit;
|
|
}
|
|
}
|
|
}
|
|
|
|
return FindNearestAliveUnit(nearPos, maxDist, asset != null ? asset.id : null);
|
|
}
|
|
|
|
private static IEnumerable<Actor> EnumerateAliveUnits()
|
|
{
|
|
return EnumerateAliveUnitsPublic();
|
|
}
|
|
|
|
public static IEnumerable<Actor> EnumerateAliveUnitsPublic()
|
|
{
|
|
List<Actor> prepared = World.world?.units?.units_only_alive;
|
|
if (prepared != null && prepared.Count > 0)
|
|
{
|
|
for (int i = 0; i < prepared.Count; i++)
|
|
{
|
|
Actor actor = prepared[i];
|
|
if (actor != null && actor.isAlive())
|
|
{
|
|
yield return actor;
|
|
}
|
|
}
|
|
|
|
yield break;
|
|
}
|
|
|
|
if (World.world?.units == null)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
foreach (Actor actor in World.world.units)
|
|
{
|
|
if (actor != null && actor.isAlive())
|
|
{
|
|
yield return actor;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string SafeName(Actor actor)
|
|
{
|
|
try
|
|
{
|
|
string name = actor.getName();
|
|
if (!string.IsNullOrEmpty(name))
|
|
{
|
|
return name;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return actor.asset != null ? actor.asset.id : "creature";
|
|
}
|
|
|
|
private static InterestEvent Clone(InterestEvent source)
|
|
{
|
|
if (source == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new InterestEvent
|
|
{
|
|
Score = source.Score,
|
|
Position = source.Position,
|
|
FollowUnit = source.FollowUnit,
|
|
Label = source.Label,
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = source.AssetId,
|
|
ParticipantCount = source.ParticipantCount,
|
|
NotableParticipantCount = source.NotableParticipantCount,
|
|
CharacterSignificance = source.CharacterSignificance
|
|
};
|
|
}
|
|
}
|