622 lines
16 KiB
C#
622 lines
16 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);
|
|
}
|
|
|
|
private static void EnsureScanned()
|
|
{
|
|
if (Time.unscaledTime - _lastScanAt < ScanInterval)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastScanAt = Time.unscaledTime;
|
|
ScanNow();
|
|
}
|
|
|
|
private static void ScanNow()
|
|
{
|
|
_cachedBattle = BuildHottestBattle();
|
|
InterestEvent bestUnit = BuildBestScoredUnit();
|
|
|
|
// Prefer an active battle when it is meaningfully hotter than quiet unit watching.
|
|
if (_cachedBattle != null)
|
|
{
|
|
_cachedBest = _cachedBattle;
|
|
return;
|
|
}
|
|
|
|
_cachedBest = bestUnit;
|
|
}
|
|
|
|
private static InterestEvent BuildHottestBattle()
|
|
{
|
|
HashSet<BattleContainer> battles = BattleKeeperManager.get();
|
|
if (battles == null || battles.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
BattleContainer best = null;
|
|
int bestDeaths = 0;
|
|
foreach (BattleContainer battle in battles)
|
|
{
|
|
if (battle?.tile == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int deaths = battle.getDeathsTotal();
|
|
if (deaths > bestDeaths)
|
|
{
|
|
bestDeaths = deaths;
|
|
best = battle;
|
|
}
|
|
}
|
|
|
|
if (best == null || bestDeaths < 1)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Vector3 pos = best.tile.posV3;
|
|
Actor nearby = FindNearestAliveUnit(pos, 14f);
|
|
return new InterestEvent
|
|
{
|
|
Tier = InterestTier.Action,
|
|
Score = 50f + bestDeaths,
|
|
Position = pos,
|
|
FollowUnit = nearby,
|
|
Label = bestDeaths >= 6 ? $"Battle ({bestDeaths} fallen)" : $"Skirmish ({bestDeaths} fallen)",
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = "live_battle"
|
|
};
|
|
}
|
|
|
|
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 score = ScoreActor(actor, speciesCounts);
|
|
if (score > bestScore + 0.01f)
|
|
{
|
|
bestScore = score;
|
|
best = actor;
|
|
nearTies.Clear();
|
|
nearTies.Add(actor);
|
|
}
|
|
else if (Mathf.Abs(score - bestScore) <= 2f)
|
|
{
|
|
nearTies.Add(actor);
|
|
}
|
|
}
|
|
|
|
if (nearTies.Count > 1)
|
|
{
|
|
best = nearTies[Rng.Next(nearTies.Count)];
|
|
bestScore = ScoreActor(best, speciesCounts);
|
|
}
|
|
|
|
if (best == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
InterestTier tier = InterestTier.Ambient;
|
|
if (best.isFavorite() || best.isKing() || best.isCityLeader())
|
|
{
|
|
tier = InterestTier.Story;
|
|
}
|
|
else if (best.has_attack_target || (best.data != null && best.data.kills >= 10))
|
|
{
|
|
tier = InterestTier.Action;
|
|
}
|
|
else if (IsSpectacle(best) || IsSingletonSpecies(best, speciesCounts))
|
|
{
|
|
tier = InterestTier.Curiosity;
|
|
}
|
|
|
|
return new InterestEvent
|
|
{
|
|
Tier = tier,
|
|
Score = bestScore,
|
|
Position = best.current_position,
|
|
FollowUnit = best,
|
|
Label = FormatUnitLabel(best, speciesCounts),
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = best.asset != null ? best.asset.id : "scored_unit"
|
|
};
|
|
}
|
|
|
|
/// <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 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)
|
|
{
|
|
float score = 0f;
|
|
if (actor.isFavorite())
|
|
{
|
|
score += 50f;
|
|
}
|
|
|
|
if (actor.isKing())
|
|
{
|
|
score += 40f;
|
|
}
|
|
else if (actor.isCityLeader())
|
|
{
|
|
score += 35f;
|
|
}
|
|
else if (actor.is_profession_warrior)
|
|
{
|
|
score += 12f;
|
|
}
|
|
|
|
if (actor.has_attack_target)
|
|
{
|
|
score += 30f;
|
|
}
|
|
|
|
int kills = actor.data != null ? actor.data.kills : 0;
|
|
score += Mathf.Min(25, kills);
|
|
|
|
string speciesId = actor.asset != null ? actor.asset.id : "unknown";
|
|
if (speciesCounts.TryGetValue(speciesId, out int pop) && pop == 1)
|
|
{
|
|
score += 15f;
|
|
}
|
|
|
|
if (actor.subspecies != null)
|
|
{
|
|
// Nascent lineage: few members in subspecies asset unit set if available.
|
|
try
|
|
{
|
|
int subCount = CountSubspeciesMembers(actor.subspecies);
|
|
if (subCount > 0 && subCount <= 3)
|
|
{
|
|
score += 10f;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (IsSpectacle(actor))
|
|
{
|
|
score += 8f;
|
|
}
|
|
|
|
score += Mathf.Min(10f, actor.renown / 100f);
|
|
// Tiny jitter so equal sheep do not stick forever.
|
|
score += (actor.getID() % 7) * 0.01f;
|
|
return score;
|
|
}
|
|
|
|
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";
|
|
if (actor.isFavorite())
|
|
{
|
|
return $"Favorite: {name}";
|
|
}
|
|
|
|
if (actor.isKing())
|
|
{
|
|
string kingdom = actor.kingdom != null ? actor.kingdom.name : "realm";
|
|
return $"King {name} of {kingdom}";
|
|
}
|
|
|
|
if (actor.isCityLeader())
|
|
{
|
|
string city = actor.city != null ? actor.city.name : "town";
|
|
return $"Leader {name} of {city}";
|
|
}
|
|
|
|
if (actor.has_attack_target)
|
|
{
|
|
return $"Fighting: {name}";
|
|
}
|
|
|
|
if (IsSpectacle(actor))
|
|
{
|
|
return $"Spectacle: {name} ({species})";
|
|
}
|
|
|
|
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
|
|
{
|
|
Tier = InterestTier.Curiosity,
|
|
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
|
|
{
|
|
Tier = source.Tier,
|
|
Score = source.Score,
|
|
Position = source.Position,
|
|
FollowUnit = source.FollowUnit,
|
|
Label = source.Label,
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = source.AssetId
|
|
};
|
|
}
|
|
}
|