worldbox-observer-mod/IdleSpectator/ActivityInterestTable.cs

895 lines
24 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 UnityEngine;
namespace IdleSpectator;
/// <summary>How interesting a unit's current AI task/job is for idle scoring.</summary>
public enum ActivityBand
{
Cold = 0,
Warm = 1,
Hot = 2
}
/// <summary>
/// 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.
/// </summary>
public static class ActivityInterestTable
{
public const float WarmScoreFloor = 18f;
public const float HotScoreFloor = 40f;
/// <summary>Activity score 0100 used by idle scoring.</summary>
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, out _);
return !string.IsNullOrEmpty(name) ? name : place;
}
/// <summary>Living actor interactee name only - never a building/place label.</summary>
public static string ActorTargetName(Actor actor)
{
ResolveTarget(actor, out string name, out bool isActor, out _, out _);
return isActor ? name : "";
}
/// <summary>Building/tile/settlement label only - never an actor name.</summary>
public static string PlaceOrObjectLabel(Actor actor)
{
ResolveTarget(actor, out _, out bool isActor, out string place, out _);
return isActor ? "" : (place ?? "");
}
/// <summary>
/// Prefer live actor targets (by name). Places/buildings stay as type labels.
/// <paramref name="placeIsObject"/> is true only for concrete building targets.
/// </summary>
public static void ResolveTarget(
Actor actor,
out string targetName,
out bool targetIsActor,
out string placeLabel,
out bool placeIsObject)
{
targetName = "";
targetIsActor = false;
placeLabel = "";
placeIsObject = false;
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_", "");
placeIsObject = true;
return;
}
placeLabel = string.IsNullOrEmpty(id) ? "a building" : id;
placeIsObject = true;
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 "";
}
}
/// <summary>Build a prose context snapshot from a live actor.</summary>
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, out bool placeIsObject);
ctx.TargetName = targetName;
ctx.TargetIsActor = targetIsActor;
string picked = PickPlaceLabel(place, ctx.CityName, ctx.KingdomName, ctx.SpeciesId);
ctx.PlaceLabel = picked;
ctx.PlaceIsObject = placeIsObject
&& !string.IsNullOrEmpty(picked)
&& !string.IsNullOrEmpty(place)
&& picked.Equals(place.Trim(), StringComparison.OrdinalIgnoreCase);
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 "";
}
/// <summary>Humanize a live trait id the same way the dossier does - manner only.</summary>
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;
}
/// <summary>
/// Prefer a concrete resolved object/building from AI targets over settlement names.
/// City/kingdom fill in only when there is no usable object/place from ResolveTarget.
/// </summary>
public static string PickPlaceLabel(
string resolvedPlace,
string cityName,
string kingdomName,
string speciesId)
{
if (IsConcreteResolvedPlace(resolvedPlace, speciesId))
{
return resolvedPlace.Trim();
}
if (IsUsablePlace(cityName, speciesId))
{
return cityName.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);
}
/// <summary>
/// Civ kingdom/city names tend to be multi-word or proper-cased inventions.
/// Single taxonomic tokens like "insect" fail this check.
/// </summary>
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;
}
}