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; } /// /// 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; } }