1652 lines
51 KiB
C#
1652 lines
51 KiB
C#
using System;
|
||
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
|
||
{
|
||
/// <summary>Seed ids - live actor_library discovery extends this each refresh.</summary>
|
||
private static readonly string[] SpectacleAssetSeed =
|
||
{
|
||
"dragon",
|
||
"greg",
|
||
"demon",
|
||
"evil_mage",
|
||
"white_mage",
|
||
"necromancer",
|
||
"plague_doctor",
|
||
"druid",
|
||
"ufo",
|
||
"cold_one",
|
||
"walker",
|
||
"snowman",
|
||
"bandit",
|
||
"tumor_monster",
|
||
"bioblob",
|
||
"assimilator",
|
||
"lil_pumpkin",
|
||
"fire_elemental",
|
||
"skeleton",
|
||
"zombie",
|
||
"ghost",
|
||
"mush",
|
||
"crabzilla"
|
||
};
|
||
|
||
private static readonly string[] SpectacleIdTokens =
|
||
{
|
||
"mage",
|
||
"necromancer",
|
||
"druid",
|
||
"plague_doctor",
|
||
"dragon",
|
||
"demon",
|
||
"cold_one",
|
||
"tumor",
|
||
"bioblob",
|
||
"assimilator",
|
||
"crabzilla",
|
||
"fire_elemental",
|
||
"god_finger"
|
||
};
|
||
|
||
private static HashSet<string> _spectacleAssetIds;
|
||
private static float _spectacleIdsCachedAt = -999f;
|
||
private const float SpectacleIdsCacheSeconds = 30f;
|
||
|
||
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;
|
||
private static Dictionary<string, int> _speciesCountCache;
|
||
private static float _speciesCountCachedAt = -999f;
|
||
private const float SpeciesCountCacheSeconds = 2.5f;
|
||
private static List<Actor> _lastAllFighters;
|
||
private static float _lastAllFightersAt = -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>
|
||
/// Cheap battle probe for sticky combat: BattleKeeper tiles + one chunk cluster at
|
||
/// <paramref name="anchor"/>. Never walks every alive unit.
|
||
/// </summary>
|
||
public static InterestEvent FindHottestBattleLight(Vector3 anchor)
|
||
{
|
||
return Clone(BuildHottestBattleLight(anchor));
|
||
}
|
||
|
||
/// <summary>True when a combat cluster exists near <paramref name="pos"/> (chunk-local).</summary>
|
||
public static bool HasLiveCombatNear(Vector3 pos, float radius = 16f)
|
||
{
|
||
if (LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble)
|
||
&& ensemble != null
|
||
&& ensemble.ParticipantCount >= 1)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
HashSet<BattleContainer> battles = BattleKeeperManager.get();
|
||
if (battles == null || battles.Count == 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
float r2 = radius * radius;
|
||
foreach (BattleContainer battle in battles)
|
||
{
|
||
if (battle?.tile == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Vector3 bpos = battle.tile.posV3;
|
||
float dx = bpos.x - pos.x;
|
||
float dy = bpos.y - pos.y;
|
||
if (dx * dx + dy * dy > r2)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// Deaths alone are not live combat - finished battle tiles near a cold unit
|
||
// used to pin CombatStillActive forever.
|
||
if (LiveEnsemble.TryBuildCombat(bpos, 14f, out LiveEnsemble near)
|
||
&& near != null
|
||
&& near.ParticipantCount >= 1)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Register up to <paramref name="max"/> character-led vignette seeds into the registry.
|
||
/// Samples current combatants only - never sorts the full alive unit list.
|
||
/// </summary>
|
||
public static void RegisterTopCharacterCandidates(int max = 4)
|
||
{
|
||
// Sticky mass combat already owns the camera; skip redundant vignette work.
|
||
InterestCandidate current = InterestDirector.CurrentCandidate;
|
||
if (current != null
|
||
&& current.Completion == InterestCompletionKind.CombatActive
|
||
&& current.ParticipantCount >= 4
|
||
&& InterestDirector.CurrentIsActive)
|
||
{
|
||
return;
|
||
}
|
||
|
||
EnsureScanned();
|
||
List<Actor> fighters = _lastAllFighters;
|
||
if (fighters == null
|
||
|| fighters.Count == 0
|
||
|| Time.unscaledTime - _lastAllFightersAt > ScanInterval * 2f)
|
||
{
|
||
fighters = new List<Actor>(64);
|
||
LiveEnsemble.CollectAllCombatFighters(fighters);
|
||
_lastAllFighters = fighters;
|
||
_lastAllFightersAt = Time.unscaledTime;
|
||
}
|
||
|
||
if (fighters.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Dictionary<string, int> speciesCounts = CountSpeciesPopulations();
|
||
// Linear top-N over fighters only (no O(n log n) sort of the whole world).
|
||
var top = new List<Actor>(max);
|
||
var topScores = new List<float>(max);
|
||
for (int i = 0; i < fighters.Count; i++)
|
||
{
|
||
Actor actor = fighters[i];
|
||
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
float score = ScoreActor(actor, speciesCounts);
|
||
if (top.Count < max)
|
||
{
|
||
top.Add(actor);
|
||
topScores.Add(score);
|
||
continue;
|
||
}
|
||
|
||
int worst = 0;
|
||
for (int t = 1; t < topScores.Count; t++)
|
||
{
|
||
if (topScores[t] < topScores[worst])
|
||
{
|
||
worst = t;
|
||
}
|
||
}
|
||
|
||
if (score > topScores[worst])
|
||
{
|
||
top[worst] = actor;
|
||
topScores[worst] = score;
|
||
}
|
||
}
|
||
|
||
var nearby = new List<Actor>(32);
|
||
for (int i = 0; i < top.Count; i++)
|
||
{
|
||
Actor actor = top[i];
|
||
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
long id = EventFeedUtil.SafeId(actor);
|
||
if (id == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Actor foe = null;
|
||
try
|
||
{
|
||
if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor())
|
||
{
|
||
foe = actor.attack_target.a;
|
||
if (foe != null && !foe.isAlive())
|
||
{
|
||
foe = null;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
foe = null;
|
||
}
|
||
|
||
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
|
||
PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
|
||
nearby.Clear();
|
||
LiveEnsemble.CollectCombatFightersNear(fighters, actor.current_position, 10f, nearby);
|
||
LiveEnsemble ensemble = LiveEnsemble.FromCombatFighters(nearby, actor.current_position);
|
||
int participantCount = ensemble != null ? ensemble.ParticipantCount : 1;
|
||
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
|
||
participantCount = Mathf.Max(1, participantCount);
|
||
if (ensemble != null && ensemble.HasFocus)
|
||
{
|
||
if (ensemble.ParticipantCount > 2
|
||
|| LiveEnsemble.HasOpposingCollectiveSides(ensemble)
|
||
|| ensemble.Scale > EnsembleScale.Pair)
|
||
{
|
||
follow = ensemble.Focus;
|
||
related = ensemble.Related ?? related;
|
||
}
|
||
else
|
||
{
|
||
related = related ?? ensemble.Related;
|
||
}
|
||
}
|
||
|
||
string label = ensemble != null
|
||
? EventReason.Combat(ensemble)
|
||
: EventReason.Fight(follow, related);
|
||
var candidate = new InterestCandidate
|
||
{
|
||
Key = EventCatalog.Combat.KeyFor(follow ?? actor, related),
|
||
LeadKind = InterestLeadKind.EventLed,
|
||
Category = "Combat",
|
||
Source = "scanner",
|
||
EventStrength = Mathf.Max(actionScore, EventCatalog.Combat.ScannerStrengthFloor),
|
||
CharacterSignificance = charScore,
|
||
VisualConfidence = 0.7f,
|
||
Position = follow != null ? follow.current_position : actor.current_position,
|
||
FollowUnit = follow,
|
||
RelatedUnit = related,
|
||
SubjectId = follow != null ? EventFeedUtil.SafeId(follow) : id,
|
||
RelatedId = related != null ? EventFeedUtil.SafeId(related) : 0,
|
||
Label = label,
|
||
Verb = "fighting",
|
||
AssetId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : "scored_unit"),
|
||
SpeciesId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : ""),
|
||
CityKey = follow?.city != null ? (follow.city.name ?? "") : (actor.city != null ? (actor.city.name ?? "") : ""),
|
||
KingdomKey = follow?.kingdom != null ? (follow.kingdom.name ?? "") : (actor.kingdom != null ? (actor.kingdom.name ?? "") : ""),
|
||
ParticipantCount = participantCount,
|
||
NotableParticipantCount = notables,
|
||
CreatedAt = Time.unscaledTime,
|
||
LastSeenAt = Time.unscaledTime,
|
||
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ScannerTtl,
|
||
MinWatch = EventCatalog.Combat.MinWatch,
|
||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||
Completion = InterestCompletionKind.CombatActive
|
||
};
|
||
candidate.StampCombatPair(follow ?? actor, related);
|
||
if (ensemble != null)
|
||
{
|
||
StampParticipantIds(candidate, ensemble);
|
||
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
||
{
|
||
candidate.AssetId = "live_battle";
|
||
}
|
||
|
||
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)
|
||
&& ensemble.Frame == EnsembleFrame.KingdomVsKingdom)
|
||
{
|
||
candidate.KingdomKey = ensemble.SideA.Key;
|
||
}
|
||
}
|
||
|
||
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();
|
||
// Busy war maps: skip full O(units) scoring, but still consider live spectacles
|
||
// (evil mage / dragon) so mass civ scraps cannot erase them forever.
|
||
if (_cachedBattle != null && _cachedBattle.ParticipantCount >= 3)
|
||
{
|
||
InterestEvent spectacle = BuildBestSpectacleUnit();
|
||
if (spectacle != null
|
||
&& (_cachedBattle.FollowUnit == null || !IsSpectacle(_cachedBattle.FollowUnit))
|
||
&& _cachedBattle.NotableParticipantCount <= 0)
|
||
{
|
||
// Anonymous scrap vs a live spectacle - ambient follows the spectacle.
|
||
_cachedBest = spectacle;
|
||
return;
|
||
}
|
||
|
||
_cachedBest = PreferRicherAction(_cachedBattle, spectacle) ?? _cachedBattle;
|
||
return;
|
||
}
|
||
|
||
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()
|
||
{
|
||
// One full-world fighter pass shared by BattleKeeper + live cluster ranking.
|
||
// Avoids the old O(fighters × units) TryBuildCombat-per-seed path.
|
||
// Sticky combat must not call this - use BuildHottestBattleLight instead.
|
||
var allFighters = new List<Actor>(256);
|
||
LiveEnsemble.CollectAllCombatFighters(allFighters);
|
||
_lastAllFighters = allFighters;
|
||
_lastAllFightersAt = Time.unscaledTime;
|
||
|
||
InterestEvent best = RankBattleKeeper(allFighters);
|
||
InterestEvent live = BuildHottestFightCluster(allFighters);
|
||
return PreferRicherAction(best, live);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sticky-safe battle ranking: BattleKeeper + chunk cluster at <paramref name="anchor"/>.
|
||
/// No CollectAllCombatFighters.
|
||
/// </summary>
|
||
private static InterestEvent BuildHottestBattleLight(Vector3 anchor)
|
||
{
|
||
InterestEvent best = RankBattleKeeper(allFighters: null);
|
||
if (LiveEnsemble.TryBuildCombat(anchor, 14f, out LiveEnsemble local)
|
||
&& local != null
|
||
&& local.ParticipantCount >= 1)
|
||
{
|
||
InterestEvent localEvent = FromEnsembleBattle(local, anchor, deaths: 0);
|
||
best = PreferRicherAction(best, localEvent);
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
private static InterestEvent RankBattleKeeper(List<Actor> allFighters)
|
||
{
|
||
HashSet<BattleContainer> battles = BattleKeeperManager.get();
|
||
if (battles == null || battles.Count == 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
InterestEvent best = null;
|
||
float bestScore = float.MinValue;
|
||
var nearby = allFighters != null ? new List<Actor>(32) : null;
|
||
foreach (BattleContainer battle in battles)
|
||
{
|
||
if (battle?.tile == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
int deaths = battle.getDeathsTotal();
|
||
Vector3 pos = battle.tile.posV3;
|
||
LiveEnsemble ensemble;
|
||
if (allFighters != null)
|
||
{
|
||
nearby.Clear();
|
||
LiveEnsemble.CollectCombatFightersNear(allFighters, pos, 14f, nearby);
|
||
ensemble = LiveEnsemble.FromCombatFighters(nearby, pos);
|
||
}
|
||
else if (!LiveEnsemble.TryBuildCombat(pos, 14f, out ensemble))
|
||
{
|
||
ensemble = null;
|
||
}
|
||
|
||
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
|
||
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
|
||
Actor follow = ensemble != null ? ensemble.Focus : null;
|
||
if (deaths < 1 && fighters < 2)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
fighters = Mathf.Max(fighters, deaths > 0 ? 2 : 0);
|
||
float score = ScoreFightCluster(deaths, fighters, notables);
|
||
if (score <= bestScore)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
bestScore = score;
|
||
best = FromEnsembleBattle(ensemble, pos, deaths, follow, fighters, notables, score);
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
private static InterestEvent FromEnsembleBattle(
|
||
LiveEnsemble ensemble,
|
||
Vector3 pos,
|
||
int deaths,
|
||
Actor follow = null,
|
||
int fighters = -1,
|
||
int notables = -1,
|
||
float score = -1f)
|
||
{
|
||
if (ensemble != null)
|
||
{
|
||
if (fighters < 0)
|
||
{
|
||
fighters = ensemble.ParticipantCount;
|
||
}
|
||
|
||
if (notables < 0)
|
||
{
|
||
notables = ensemble.NotableParticipantCount;
|
||
}
|
||
|
||
if (follow == null)
|
||
{
|
||
follow = ensemble.Focus;
|
||
}
|
||
}
|
||
|
||
fighters = Mathf.Max(0, fighters);
|
||
notables = Mathf.Max(0, notables);
|
||
if (score < 0f)
|
||
{
|
||
score = ScoreFightCluster(deaths, fighters, notables);
|
||
}
|
||
|
||
Actor focus = follow ?? FindNearestAliveUnit(pos, 14f);
|
||
string label = ensemble != null && ensemble.HasFocus
|
||
? EventReason.Combat(ensemble)
|
||
: EventReason.Battle(focus, fighters);
|
||
return new InterestEvent
|
||
{
|
||
Score = score,
|
||
Position = pos,
|
||
FollowUnit = focus,
|
||
RelatedUnit = ensemble != null ? ensemble.Related : null,
|
||
Label = label,
|
||
CreatedAt = Time.unscaledTime,
|
||
AssetId = "live_battle",
|
||
ParticipantCount = fighters,
|
||
NotableParticipantCount = notables,
|
||
CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Cluster of units currently in combat (even before BattleKeeper records deaths).
|
||
/// One pass over a precollected fighter list - no per-seed world scans.
|
||
/// </summary>
|
||
private static InterestEvent BuildHottestFightCluster(List<Actor> allFighters)
|
||
{
|
||
if (allFighters == null || allFighters.Count == 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
InterestEvent best = null;
|
||
float bestScore = float.MinValue;
|
||
var claimed = new HashSet<long>();
|
||
var cluster = new List<Actor>(32);
|
||
const float clusterRadius = 10f;
|
||
|
||
for (int i = 0; i < allFighters.Count; i++)
|
||
{
|
||
Actor actor = allFighters[i];
|
||
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
long id = EventFeedUtil.SafeId(actor);
|
||
if (id == 0 || claimed.Contains(id))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Vector2 seedPos = actor.current_position;
|
||
cluster.Clear();
|
||
LiveEnsemble.CollectCombatFightersNear(allFighters, seedPos, clusterRadius, cluster);
|
||
if (cluster.Count < 1)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
for (int c = 0; c < cluster.Count; c++)
|
||
{
|
||
long cid = EventFeedUtil.SafeId(cluster[c]);
|
||
if (cid != 0)
|
||
{
|
||
claimed.Add(cid);
|
||
}
|
||
}
|
||
|
||
LiveEnsemble ensemble = LiveEnsemble.FromCombatFighters(cluster, seedPos);
|
||
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
|
||
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
|
||
Actor follow = ensemble != null ? ensemble.Focus : null;
|
||
if (fighters < 1)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
float score = ScoreFightCluster(deaths: 0, fighters, notables);
|
||
if (score > bestScore)
|
||
{
|
||
bestScore = score;
|
||
Actor focus = follow ?? actor;
|
||
string label = ensemble != null && ensemble.HasFocus
|
||
? EventReason.Combat(ensemble)
|
||
: EventReason.Battle(focus, fighters);
|
||
best = new InterestEvent
|
||
{
|
||
Score = score,
|
||
Position = actor.current_position,
|
||
FollowUnit = focus,
|
||
RelatedUnit = ensemble != null ? ensemble.Related : null,
|
||
Label = label,
|
||
CreatedAt = Time.unscaledTime,
|
||
AssetId = "live_battle",
|
||
ParticipantCount = fighters,
|
||
NotableParticipantCount = notables,
|
||
CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable
|
||
};
|
||
}
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Character weight for picking who to watch in a fight (notable / renown / role).
|
||
/// Matches <see cref="CountFightCluster"/> bestFollow ranking.
|
||
/// </summary>
|
||
public static float CombatFocusWeight(Actor actor)
|
||
{
|
||
if (actor == null || !actor.isAlive())
|
||
{
|
||
return -1f;
|
||
}
|
||
|
||
SplitActorScore(actor, null, out _, out float charScore);
|
||
float weight = charScore;
|
||
if (InterestScoring.IsExtremelyNotable(actor))
|
||
{
|
||
weight += 30f;
|
||
}
|
||
|
||
// Spectacle creatures (evil mage, dragon, …) win theater-lead picks in mob fights.
|
||
// Use a dedicated floor so char-only weights among civs cannot outrank them.
|
||
if (IsSpectacle(actor))
|
||
{
|
||
ScoringWeights w = InterestScoringConfig.W;
|
||
float spectacle = w.scannerSpectacleBonus > 0f ? w.scannerSpectacleBonus : 12f;
|
||
weight += Mathf.Max(35f, spectacle);
|
||
}
|
||
|
||
return weight;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prefer the higher-scored of two combatants as follow; the other becomes related.
|
||
/// </summary>
|
||
public static void PreferCombatFollow(Actor a, Actor b, out Actor follow, out Actor related)
|
||
{
|
||
// Stable ownership: never swap A↔B by momentary weight (that thrashes Duel tips).
|
||
// Caller / first claimant stays Follow; dead follow hands off to related.
|
||
follow = a;
|
||
related = b;
|
||
if (a == null || !a.isAlive())
|
||
{
|
||
follow = b != null && b.isAlive() ? b : null;
|
||
related = null;
|
||
return;
|
||
}
|
||
|
||
if (b == null || !b.isAlive())
|
||
{
|
||
related = null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Highest-scored living participant for an active combat scene.
|
||
/// Considers Follow, Related, and (for mass fights) the nearby fight ensemble.
|
||
/// </summary>
|
||
public static bool TryPickBestCombatFocus(
|
||
InterestCandidate scene,
|
||
out Actor best,
|
||
out Actor foe,
|
||
out int fighters)
|
||
{
|
||
best = null;
|
||
foe = null;
|
||
fighters = 1;
|
||
if (scene == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
LiveEnsemble ensemble = null;
|
||
Vector3 pos = scene.Position;
|
||
if (scene.FollowUnit != null && scene.FollowUnit.isAlive())
|
||
{
|
||
pos = scene.FollowUnit.current_position;
|
||
}
|
||
else if (scene.RelatedUnit != null && scene.RelatedUnit.isAlive())
|
||
{
|
||
pos = scene.RelatedUnit.current_position;
|
||
}
|
||
|
||
bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||
|| scene.ParticipantCount > 2;
|
||
float radius = mass ? 14f : 10f;
|
||
LiveEnsemble.TryBuildCombat(
|
||
pos,
|
||
radius,
|
||
out ensemble,
|
||
priorParticipantIds: scene.ParticipantIds,
|
||
newcomerBonus: 8f);
|
||
|
||
if (ensemble != null && ensemble.HasFocus)
|
||
{
|
||
best = ensemble.Focus;
|
||
foe = ensemble.Related;
|
||
fighters = Mathf.Max(1, ensemble.ParticipantCount);
|
||
return true;
|
||
}
|
||
|
||
fighters = Mathf.Max(1, scene.ParticipantCount);
|
||
float bestWeight = -1f;
|
||
Actor bestLocal = null;
|
||
|
||
void Consider(Actor unit)
|
||
{
|
||
if (unit == null || !unit.isAlive())
|
||
{
|
||
return;
|
||
}
|
||
|
||
float w = LiveEnsemble.FocusWeight(unit, scene.ParticipantIds, 8f);
|
||
if (w > bestWeight)
|
||
{
|
||
bestWeight = w;
|
||
bestLocal = unit;
|
||
}
|
||
}
|
||
|
||
Consider(scene.FollowUnit);
|
||
Consider(scene.RelatedUnit);
|
||
best = bestLocal;
|
||
if (best == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
foe = null;
|
||
try
|
||
{
|
||
if (best.has_attack_target
|
||
&& best.attack_target != null
|
||
&& best.attack_target.isAlive()
|
||
&& best.attack_target.isActor())
|
||
{
|
||
foe = best.attack_target.a;
|
||
if (foe != null && !foe.isAlive())
|
||
{
|
||
foe = null;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
foe = null;
|
||
}
|
||
|
||
if (foe == null)
|
||
{
|
||
Actor related = scene.RelatedUnit;
|
||
Actor follow = scene.FollowUnit;
|
||
if (related != null && related.isAlive() && related != best)
|
||
{
|
||
foe = related;
|
||
}
|
||
else if (follow != null && follow.isAlive() && follow != best)
|
||
{
|
||
foe = follow;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Build a combat ensemble snapshot at a world position.</summary>
|
||
public static bool TryBuildCombatEnsemble(
|
||
Vector2 pos,
|
||
float radius,
|
||
out LiveEnsemble ensemble,
|
||
InterestCandidate prior = null)
|
||
{
|
||
return LiveEnsemble.TryBuildCombat(
|
||
pos,
|
||
radius,
|
||
out ensemble,
|
||
priorParticipantIds: prior?.ParticipantIds,
|
||
newcomerBonus: 8f);
|
||
}
|
||
|
||
public static void StampParticipantIds(InterestCandidate candidate, LiveEnsemble ensemble)
|
||
{
|
||
if (candidate == null || ensemble == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
candidate.ParticipantIds.Clear();
|
||
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
|
||
{
|
||
long id = ensemble.ParticipantIds[i];
|
||
if (id != 0 && !candidate.ParticipantIds.Contains(id))
|
||
{
|
||
candidate.ParticipantIds.Add(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
if (!LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble))
|
||
{
|
||
return;
|
||
}
|
||
|
||
fighters = ensemble.ParticipantCount;
|
||
notables = ensemble.NotableParticipantCount;
|
||
bestFollow = ensemble.Focus;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Prefer spectacles over civ ambient. Idle/warm chores never beat a live mage/dragon;
|
||
// Hot combatants still need the prefer margin (spectacles should not erase every scrap).
|
||
if (!IsSpectacle(best))
|
||
{
|
||
Actor spectacle = null;
|
||
float spectacleScore = float.MinValue;
|
||
for (int i = 0; i < alive.Count; i++)
|
||
{
|
||
Actor actor = alive[i];
|
||
if (!IsSpectacle(actor))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
float unitScore = ScoreActor(actor, speciesCounts);
|
||
if (unitScore > spectacleScore)
|
||
{
|
||
spectacleScore = unitScore;
|
||
spectacle = actor;
|
||
}
|
||
}
|
||
|
||
if (spectacle != null)
|
||
{
|
||
bool bestHotFight = best.has_attack_target
|
||
&& ActivityInterestTable.Classify(best) == ActivityBand.Hot;
|
||
float preferMargin = InterestScoringConfig.W.scannerSpectaclePreferMargin > 0f
|
||
? InterestScoringConfig.W.scannerSpectaclePreferMargin
|
||
: 28f;
|
||
if (!bestHotFight || spectacleScore + preferMargin >= bestScore)
|
||
{
|
||
best = spectacle;
|
||
bestScore = spectacleScore;
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
string label = "";
|
||
if (best.has_attack_target)
|
||
{
|
||
Actor foe = null;
|
||
try
|
||
{
|
||
if (best.attack_target != null && best.attack_target.isAlive() && best.attack_target.isActor())
|
||
{
|
||
foe = best.attack_target.a;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
foe = null;
|
||
}
|
||
|
||
label = EventReason.Fight(best, foe);
|
||
}
|
||
|
||
return new InterestEvent
|
||
{
|
||
Score = finalScore,
|
||
Position = best.current_position,
|
||
FollowUnit = best,
|
||
Label = label,
|
||
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()
|
||
{
|
||
float now = Time.unscaledTime;
|
||
if (_speciesCountCache != null && now - _speciesCountCachedAt < SpeciesCountCacheSeconds)
|
||
{
|
||
return _speciesCountCache;
|
||
}
|
||
|
||
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]++;
|
||
}
|
||
|
||
_speciesCountCache = speciesCounts;
|
||
_speciesCountCachedAt = now;
|
||
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.
|
||
float score = action + character * InterestScoringConfig.W.scannerRankCharWeight;
|
||
// Life-saga bias for ambient fill - prefer roster MCs (Prefer first) over strangers.
|
||
long id = EventFeedUtil.SafeId(actor);
|
||
if (LifeSagaRoster.IsPrefer(id))
|
||
{
|
||
score += Mathf.Max(4f, InterestScoringConfig.Story.sagaPreferWeight * 0.45f);
|
||
}
|
||
else if (LifeSagaRoster.IsMc(id))
|
||
{
|
||
score += Mathf.Max(2f, InterestScoringConfig.Story.sagaMcWeight * 0.45f);
|
||
}
|
||
|
||
float causal = CausalHeat.Get(id);
|
||
if (causal > 0f)
|
||
{
|
||
score += causal * 2f;
|
||
}
|
||
|
||
return score;
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
|
||
bool spectacle = IsSpectacle(actor);
|
||
if (spectacle)
|
||
{
|
||
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;
|
||
}
|
||
|
||
// Policy overlay (evil_mage / dragon / …) - same source as InterestScoring meta.
|
||
float speciesBonus = EventCatalogConfig.SpeciesBonus(speciesId);
|
||
if (speciesBonus > 0f)
|
||
{
|
||
characterScore += speciesBonus;
|
||
}
|
||
|
||
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.
|
||
// Spectacles keep full action (idle floor applied below).
|
||
if (band == ActivityBand.Cold && !actor.has_attack_target && !spectacle)
|
||
{
|
||
actionScore *= w.scannerColdActionMul;
|
||
characterScore *= w.scannerColdCharMul;
|
||
}
|
||
|
||
// Idle evil mages / dragons still beat warm chores for ambient fill.
|
||
if (spectacle && band == ActivityBand.Cold && !actor.has_attack_target)
|
||
{
|
||
float floor = w.scannerSpectacleIdleFloor > 0f ? w.scannerSpectacleIdleFloor : 42f;
|
||
actionScore = Mathf.Max(actionScore, floor);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
return IsSpectacleAssetId(actor.asset.id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Live spectacle actor / power id class (mages, dragons, demons, …).
|
||
/// Seed + token heuristics + live <c>actor_library</c> discovery.
|
||
/// </summary>
|
||
public static bool IsSpectacleAssetId(string assetId)
|
||
{
|
||
if (string.IsNullOrEmpty(assetId))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
string id = assetId.Trim().ToLowerInvariant();
|
||
if (id.StartsWith("boat_", StringComparison.Ordinal))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
HashSet<string> live = EnsureSpectacleAssetIds();
|
||
if (live.Contains(id))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return MatchesSpectacleToken(id);
|
||
}
|
||
|
||
private static HashSet<string> EnsureSpectacleAssetIds()
|
||
{
|
||
float now = Time.unscaledTime;
|
||
if (_spectacleAssetIds != null && now - _spectacleIdsCachedAt < SpectacleIdsCacheSeconds)
|
||
{
|
||
return _spectacleAssetIds;
|
||
}
|
||
|
||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
for (int i = 0; i < SpectacleAssetSeed.Length; i++)
|
||
{
|
||
set.Add(SpectacleAssetSeed[i]);
|
||
}
|
||
|
||
List<string> liveActors = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
|
||
for (int i = 0; i < liveActors.Count; i++)
|
||
{
|
||
string id = liveActors[i];
|
||
if (string.IsNullOrEmpty(id) || id.StartsWith("boat_", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (MatchesSpectacleToken(id) || set.Contains(id))
|
||
{
|
||
set.Add(id);
|
||
}
|
||
}
|
||
|
||
_spectacleAssetIds = set;
|
||
_spectacleIdsCachedAt = now;
|
||
return _spectacleAssetIds;
|
||
}
|
||
|
||
private static bool MatchesSpectacleToken(string id)
|
||
{
|
||
if (string.IsNullOrEmpty(id))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
string s = id.ToLowerInvariant();
|
||
for (int i = 0; i < SpectacleIdTokens.Length; i++)
|
||
{
|
||
if (s.IndexOf(SpectacleIdTokens[i], StringComparison.Ordinal) >= 0)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
for (int i = 0; i < SpectacleAssetSeed.Length; i++)
|
||
{
|
||
if (s.IndexOf(SpectacleAssetSeed[i], StringComparison.OrdinalIgnoreCase) >= 0)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>Best living spectacle unit for ambient ranking (mages, dragons, …).</summary>
|
||
private static InterestEvent BuildBestSpectacleUnit()
|
||
{
|
||
Actor best = null;
|
||
float bestScore = float.MinValue;
|
||
Dictionary<string, int> counts = CountSpeciesPopulations();
|
||
foreach (Actor actor in EnumerateAliveUnits())
|
||
{
|
||
if (actor == null || !actor.isAlive() || !IsSpectacle(actor))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (actor.asset != null && actor.asset.is_boat)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
float score = ScoreActor(actor, counts);
|
||
if (score > bestScore)
|
||
{
|
||
bestScore = score;
|
||
best = actor;
|
||
}
|
||
}
|
||
|
||
if (best == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
SplitActorScore(best, counts, out float actionScore, out float charScore);
|
||
float rankChar = InterestScoringConfig.W.scannerRankCharWeight;
|
||
return new InterestEvent
|
||
{
|
||
Score = actionScore + charScore * rankChar,
|
||
Position = best.current_position,
|
||
FollowUnit = best,
|
||
Label = "",
|
||
CreatedAt = Time.unscaledTime,
|
||
AssetId = best.asset != null ? best.asset.id : "spectacle",
|
||
ParticipantCount = 0,
|
||
NotableParticipantCount = 0,
|
||
CharacterSignificance = charScore
|
||
};
|
||
}
|
||
|
||
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
|
||
};
|
||
}
|
||
}
|