worldbox-observer-mod/IdleSpectator/WorldActivityScanner.cs
DazedAnon c23b2b9c1e feat(spectator): add crisis chapters and harden combat theater truth
- Open war/disaster/outbreak crisis overlays with epilogue_crisis closers
- Keep Duel pairs hot only on owned cast; park sleep/freeze; drop ambient scrap pins
- Require a fallen theater partner for survivor aftermath; let king_killed cut mid-fight
- Gate crisis, combat cold, king cut-in, and living-partner aftermath in harness
2026-07-21 22:05:38 -05:00

1649 lines
51 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
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;
// Character Ledger bias for ambient fill - prefer watched lives over strangers.
long id = EventFeedUtil.SafeId(actor);
float ledger = CharacterLedger.Heat(id);
if (ledger > 0f)
{
score += ledger * Mathf.Max(1f, InterestScoringConfig.Story.ledgerWeight * 0.35f);
}
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
};
}
}