using System;
using UnityEngine;
namespace IdleSpectator;
/// How interesting a unit's current AI task/job is for idle scoring.
public enum ActivityBand
{
Cold = 0,
Warm = 1,
Hot = 2
}
///
/// Maps WorldBox task / citizen-job ids (and combat flags) to Hot/Warm/Cold interest.
/// Unknown ids default to Warm if they look like work, else Cold.
///
public static class ActivityInterestTable
{
public const float WarmScoreFloor = 18f;
public const float HotScoreFloor = 40f;
/// Activity score 0–100 used by idle scoring.
public static float ScoreActivity(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return 0f;
}
ActivityBand band = Classify(actor);
float score = band switch
{
ActivityBand.Hot => 55f,
ActivityBand.Warm => 28f,
_ => 4f
};
if (actor.has_attack_target)
{
score = Mathf.Max(score, 70f);
}
try
{
if (actor.hasTask() && actor.ai?.task != null && actor.ai.task.in_combat)
{
score = Mathf.Max(score, 72f);
}
if (actor.hasTask() && actor.ai?.task != null && actor.ai.task.is_fireman)
{
score = Mathf.Max(score, 60f);
}
}
catch
{
// ignore
}
return Mathf.Clamp(score, 0f, 100f);
}
public static ActivityBand Classify(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return ActivityBand.Cold;
}
if (actor.has_attack_target)
{
return ActivityBand.Hot;
}
string taskId = SafeTaskId(actor);
string jobId = SafeJobId(actor);
ActivityBand fromTask = ClassifyId(taskId);
ActivityBand fromJob = ClassifyId(jobId);
ActivityBand best = fromTask > fromJob ? fromTask : fromJob;
try
{
if (actor.hasTask() && actor.ai?.task != null)
{
if (actor.ai.task.in_combat || actor.ai.task.is_fireman)
{
return ActivityBand.Hot;
}
}
}
catch
{
// ignore
}
return best;
}
public static ActivityBand ClassifyId(string id)
{
if (string.IsNullOrEmpty(id))
{
return ActivityBand.Cold;
}
string s = id.ToLowerInvariant();
if (ContainsAny(s,
"fight", "attack", "hunt", "fireman", "fire", "war", "kill", "danger",
"unload", "throw_resource", "capture", "siege"))
{
return ActivityBand.Hot;
}
if (ContainsAny(s,
"farm", "harvest", "plant", "fertiliz", "mine", "gather", "woodcut", "chop",
"build", "construct", "road", "smith", "blacksmith", "pollinat", "bee",
"honey", "herb", "bush", "social", "lover", "breed", "fish", "trade",
"clean", "fertiliz", "bucket", "hoe"))
{
return ActivityBand.Warm;
}
if (ContainsAny(s,
"eat", "sleep", "wait", "idle", "random_move", "random_swim", "make_decision",
"run_away", "swim_to", "print_", "godfinger", "end_job"))
{
return ActivityBand.Cold;
}
// Unknown active task: mild warm so we do not ignore novel work.
if (s.Length > 0)
{
return ActivityBand.Warm;
}
return ActivityBand.Cold;
}
public static string SafeTaskId(Actor actor)
{
try
{
if (actor != null && actor.hasTask() && actor.ai?.task != null)
{
return actor.ai.task.id ?? "";
}
}
catch
{
// ignore
}
return "";
}
public static string SafeJobId(Actor actor)
{
try
{
if (actor?.citizen_job != null)
{
return actor.citizen_job.id ?? "";
}
}
catch
{
// ignore
}
return "";
}
public static string DumpFocusProbe(Actor actor)
{
if (actor == null)
{
return "actor=null";
}
string name = "?";
try
{
name = actor.getName() ?? "?";
}
catch
{
// ignore
}
string taskId = SafeTaskId(actor);
string jobId = SafeJobId(actor);
string taskLoc = "";
try
{
if (actor.hasTask() && actor.ai?.task != null)
{
taskLoc = actor.ai.task.getLocalizedText() ?? "";
}
}
catch
{
// ignore
}
string actorTarget = TargetLabel(actor);
ActivityBand band = Classify(actor);
return
$"name={name} task='{taskId}' job='{jobId}' loc='{taskLoc}' target='{actorTarget}' "
+ $"band={band} activity={ScoreActivity(actor):0.0} attack={actor.has_attack_target}";
}
public static string TargetLabel(Actor actor)
{
ResolveTarget(actor, out string name, out _, out string place);
return !string.IsNullOrEmpty(name) ? name : place;
}
/// Living actor interactee name only - never a building/place label.
public static string ActorTargetName(Actor actor)
{
ResolveTarget(actor, out string name, out bool isActor, out _);
return isActor ? name : "";
}
/// Building/tile/settlement label only - never an actor name.
public static string PlaceOrObjectLabel(Actor actor)
{
ResolveTarget(actor, out _, out bool isActor, out string place);
return isActor ? "" : (place ?? "");
}
///
/// Prefer live actor targets (by name). Places/buildings stay as type labels.
///
public static void ResolveTarget(
Actor actor,
out string targetName,
out bool targetIsActor,
out string placeLabel)
{
targetName = "";
targetIsActor = false;
placeLabel = "";
if (actor == null)
{
return;
}
try
{
if (actor.has_attack_target && actor.attack_target != null && actor.attack_target.isAlive())
{
if (actor.attack_target.isActor())
{
Actor foe = actor.attack_target.a;
if (foe != null)
{
string n = foe.getName();
if (!string.IsNullOrEmpty(n))
{
targetName = n;
targetIsActor = true;
return;
}
}
}
}
if (actor.beh_actor_target != null && actor.beh_actor_target.isAlive())
{
if (actor.beh_actor_target.isActor())
{
Actor other = actor.beh_actor_target.a;
if (other != null)
{
string n = other.getName();
if (!string.IsNullOrEmpty(n))
{
targetName = n;
targetIsActor = true;
return;
}
}
}
placeLabel = "their mark";
return;
}
if (actor.beh_building_target != null && actor.beh_building_target.isAlive())
{
Building b = actor.beh_building_target;
string type = b.asset != null ? b.asset.type : "";
string id = b.asset != null ? b.asset.id : "";
if (!string.IsNullOrEmpty(type))
{
placeLabel = type.Replace("type_", "");
return;
}
placeLabel = string.IsNullOrEmpty(id) ? "a building" : id;
return;
}
if (actor.beh_tile_target != null)
{
placeLabel = "the wilds";
}
// Prefer settlement names when no building/tile label won.
if (string.IsNullOrEmpty(placeLabel) && string.IsNullOrEmpty(targetName))
{
try
{
if (actor.city != null && !string.IsNullOrEmpty(actor.city.name))
{
placeLabel = actor.city.name;
}
else if (actor.kingdom != null && !string.IsNullOrEmpty(actor.kingdom.name))
{
placeLabel = actor.kingdom.name;
}
}
catch
{
// ignore
}
}
}
catch
{
// ignore
}
}
public static string ActorName(Actor actor)
{
if (actor == null)
{
return "";
}
try
{
string n = actor.getName();
return string.IsNullOrEmpty(n) ? "" : n;
}
catch
{
return "";
}
}
/// Build a prose context snapshot from a live actor.
public static ActivityContext BuildContext(Actor actor, string taskKeyHint = null)
{
ActivityContext ctx = new ActivityContext();
if (actor == null || !actor.isAlive())
{
return ctx;
}
ctx.ActorName = ActorName(actor);
try
{
if (actor.asset != null)
{
ActorAsset asset = actor.asset;
ActivityVoiceResolver.ApplyToContext(ctx, asset);
ctx.SpeciesLabel = ActivityAssetCatalog.SpeciesDisplayLabel(asset);
}
else
{
ctx.SpeciesId = "";
ctx.SpeciesLabel = "";
ctx.Family = ActivityVoiceFamilies.Default;
ctx.Voice = ProseVoice.Unknown();
}
}
catch
{
ctx.SpeciesId = "";
ctx.SpeciesLabel = "";
ctx.Family = ActivityVoiceFamilies.Default;
ctx.Voice = ProseVoice.Unknown();
}
if (ctx.Voice == null)
{
ctx.Voice = ActivityVoiceResolver.Resolve(ctx.SpeciesId);
ctx.Family = ctx.Voice.BaseFamily;
}
ctx.TaskKey = taskKeyHint ?? SafeTaskId(actor);
ctx.JobId = SafeJobId(actor);
ctx.Terrain = ResolveTerrain(actor);
try
{
ctx.Age = actor.getAge();
}
catch
{
ctx.Age = 0;
}
ctx.IsChild = false;
if (!string.IsNullOrEmpty(ctx.TaskKey)
&& ctx.TaskKey.StartsWith("child_", System.StringComparison.OrdinalIgnoreCase))
{
ctx.IsChild = true;
}
else if (ctx.Age > 0 && ctx.Age < 12
&& (ctx.IsHumanoid || ctx.Family == ActivityVoiceFamilies.Humanoid))
{
// Age bands are reliable for civ humanoids; animals often stay "young" by year count.
ctx.IsChild = true;
}
try
{
ctx.CityName = actor.city != null ? (actor.city.name ?? "") : "";
}
catch
{
ctx.CityName = "";
}
try
{
ctx.KingdomName = actor.kingdom != null ? (actor.kingdom.name ?? "") : "";
}
catch
{
ctx.KingdomName = "";
}
try
{
ctx.IsKing = actor.isKing();
ctx.IsLeader = actor.isCityLeader();
ctx.IsWarrior = actor.is_profession_warrior;
ctx.HasAttackTarget = actor.has_attack_target;
}
catch
{
// ignore
}
try
{
if (actor.hasTask() && actor.ai?.task != null)
{
ctx.InCombat = actor.ai.task.in_combat || actor.has_attack_target;
}
else
{
ctx.InCombat = actor.has_attack_target;
}
}
catch
{
ctx.InCombat = ctx.HasAttackTarget;
}
ResolveTarget(actor, out string targetName, out bool targetIsActor, out string place);
ctx.TargetName = targetName;
ctx.TargetIsActor = targetIsActor;
ctx.PlaceLabel = PickPlaceLabel(place, ctx.CityName, ctx.KingdomName, ctx.SpeciesId);
ctx.CarryingLabel = TryGetCarryingLabel(actor);
ctx.TopTraitId = TryTopInterestingTraitId(actor);
ctx.TopTraitLabel = TraitLabelFromId(ctx.TopTraitId);
ctx.RoleLabel = DeriveRoleLabel(ctx.IsKing, ctx.IsLeader, ctx.IsWarrior);
return ctx;
}
private static ActivityTerrain ResolveTerrain(Actor actor)
{
try
{
WorldTile tile = actor?.current_tile;
if (tile?.Type == null)
{
return ActivityTerrain.Unknown;
}
if (tile.Type.liquid)
{
return ActivityTerrain.Liquid;
}
if (tile.Type.ground)
{
return ActivityTerrain.Land;
}
}
catch
{
// A missing or changing tile is not safe evidence for environmental prose.
}
return ActivityTerrain.Unknown;
}
public static string DeriveRoleLabel(bool isKing, bool isLeader, bool isWarrior)
{
if (isKing)
{
return "king";
}
if (isLeader)
{
return "leader";
}
if (isWarrior)
{
return "warrior";
}
return "";
}
/// Humanize a live trait id the same way the dossier does - manner only.
public static string TraitLabelFromId(string traitId)
{
if (string.IsNullOrEmpty(traitId))
{
return "";
}
try
{
ActorTrait trait = AssetManager.traits?.get(traitId.Trim());
if (trait != null)
{
string locale = trait.getLocaleID();
if (!string.IsNullOrEmpty(locale))
{
string localized = LocalizedTextManager.getText(locale);
if (!string.IsNullOrEmpty(localized) && localized != locale)
{
return localized.Trim().ToLowerInvariant();
}
}
}
}
catch
{
// fall through to id humanize
}
string s = traitId.Replace('_', ' ').Trim().ToLowerInvariant();
if (s.StartsWith("trait ", StringComparison.Ordinal))
{
s = s.Substring(6).Trim();
}
return s;
}
///
/// Real places only. Reject species ids, family/taxonomy buckets, and soft markers.
/// Wild kingdoms are often named after a species or type (buffalo, insect) - those are not places.
///
public static string PickPlaceLabel(
string resolvedPlace,
string cityName,
string kingdomName,
string speciesId)
{
if (IsUsablePlace(cityName, speciesId))
{
return cityName.Trim();
}
if (IsConcreteResolvedPlace(resolvedPlace, speciesId))
{
return resolvedPlace.Trim();
}
// Kingdom only when it reads like a settlement/realm name, not a species bucket.
if (IsUsablePlace(kingdomName, speciesId) && LooksLikeRealmName(kingdomName))
{
return kingdomName.Trim();
}
if (!string.IsNullOrEmpty(resolvedPlace)
&& resolvedPlace.Equals("the wilds", System.StringComparison.OrdinalIgnoreCase))
{
return "the wilds";
}
return "";
}
private static bool IsUsablePlace(string name, string speciesId)
{
if (string.IsNullOrEmpty(name))
{
return false;
}
return !LooksLikeSpeciesLabel(name, speciesId);
}
private static bool IsConcreteResolvedPlace(string place, string speciesId)
{
if (string.IsNullOrEmpty(place))
{
return false;
}
if (place.Equals("their mark", System.StringComparison.OrdinalIgnoreCase)
|| place.Equals("the wilds", System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
return !LooksLikeSpeciesLabel(place, speciesId);
}
///
/// Civ kingdom/city names tend to be multi-word or proper-cased inventions.
/// Single taxonomic tokens like "insect" fail this check.
///
private static bool LooksLikeRealmName(string name)
{
if (string.IsNullOrEmpty(name))
{
return false;
}
string n = name.Trim();
if (n.IndexOf(' ') >= 0 || n.IndexOf('-') >= 0 || n.IndexOf('\'') >= 0)
{
return true;
}
// Single token: allow only if it does not look like a plain species/type word.
// Realms like "Rome" pass; "insect" / "buffalo" fail via LooksLikeSpeciesLabel already,
// but also reject short all-lowercase taxonomy leftovers here.
if (n.Length <= 3)
{
return false;
}
bool hasUpper = false;
for (int i = 0; i < n.Length; i++)
{
if (char.IsUpper(n[i]))
{
hasUpper = true;
break;
}
}
return hasUpper;
}
public static bool LooksLikeSpeciesPublic(string label, string speciesId)
{
return LooksLikeSpeciesLabel(label, speciesId);
}
private static readonly string[] TaxonomyBuckets =
{
"insect", "insects", "animal", "animals", "beast", "beasts", "creature", "creatures",
"monster", "monsters", "undead", "nature", "wildlife", "civilian", "civilians",
"unit", "units", "boat", "boats", "humanoid", "livestock", "bird", "birds",
"canine", "feline", "aquatic", "default", "wild"
};
private static bool LooksLikeSpeciesLabel(string label, string speciesId)
{
if (string.IsNullOrEmpty(label))
{
return false;
}
string a = label.Trim();
string aLower = a.ToLowerInvariant();
for (int i = 0; i < TaxonomyBuckets.Length; i++)
{
if (aLower.Equals(TaxonomyBuckets[i], System.StringComparison.Ordinal))
{
return true;
}
}
if (!string.IsNullOrEmpty(speciesId))
{
string b = speciesId.Trim();
if (a.Equals(b, System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
string bHuman = b.Replace('_', ' ');
if (a.Equals(bHuman, System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
string family = ActivityVoiceResolver.Resolve(speciesId).BaseFamily;
if (!string.IsNullOrEmpty(family)
&& a.Equals(family, System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
// Live actor library: any asset id / wild kingdom / locale name is not a place.
if (ActivityAssetCatalog.IsLiveSpeciesPlaceToken(a)
|| ActivityAssetCatalog.IsLiveActorAssetId(aLower))
{
return true;
}
return false;
}
public static string TryGetCarryingLabel(Actor actor)
{
if (actor == null)
{
return "";
}
try
{
if (!actor.isCarryingResources())
{
return "";
}
}
catch
{
return "";
}
try
{
ActorBag inv = actor.inventory;
string id = "";
try
{
id = inv.getItemIDToRender() ?? "";
}
catch
{
id = "";
}
if (string.IsNullOrEmpty(id))
{
try
{
id = inv.getRandomResourceID() ?? "";
}
catch
{
id = "";
}
}
if (!string.IsNullOrEmpty(id))
{
return SanitizeCarrying(HumanizeResourceId(id));
}
}
catch
{
// ignore
}
return "what they carry";
}
private static string HumanizeResourceId(string id)
{
if (string.IsNullOrEmpty(id))
{
return "";
}
return id.Replace('_', ' ').Trim();
}
private static string SanitizeCarrying(string tip)
{
if (string.IsNullOrEmpty(tip))
{
return "";
}
string s = tip.Replace("\n", " ").Replace("\r", " ").Trim();
// Strip simple rich-text tags if present.
while (true)
{
int open = s.IndexOf('<');
if (open < 0)
{
break;
}
int close = s.IndexOf('>', open);
if (close < 0)
{
break;
}
s = s.Remove(open, close - open + 1);
}
s = s.Trim();
if (s.Length > 48)
{
s = s.Substring(0, 45).Trim() + "...";
}
return s;
}
private static string TryTopInterestingTraitId(Actor actor)
{
try
{
if (actor == null || !actor.hasTraits())
{
return "";
}
foreach (ActorTrait trait in actor.getTraits())
{
if (trait == null || string.IsNullOrEmpty(trait.id))
{
continue;
}
// Skip default species traits when detectable.
try
{
if (actor.asset != null
&& trait.default_for_actor_assets != null
&& trait.default_for_actor_assets.Contains(actor.asset))
{
continue;
}
}
catch
{
// ignore
}
return trait.id;
}
}
catch
{
// ignore
}
return "";
}
private static bool ContainsAny(string hay, params string[] needles)
{
for (int i = 0; i < needles.Length; i++)
{
if (hay.IndexOf(needles[i], System.StringComparison.Ordinal) >= 0)
{
return true;
}
}
return false;
}
}