worldbox-observer-mod/IdleSpectator/WorldActivityScanner.cs
2026-07-14 14:59:26 -05:00

568 lines
15 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 = "scored_unit"
};
}
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.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()
{
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
};
}
}