using System; using System.Collections.Generic; using System.Text; namespace IdleSpectator; /// /// Snapshot of why a living unit is interesting (caption + chronicle source). /// public sealed class UnitDossier { public long UnitId; public string Name = ""; public string SpeciesId = ""; public string Headline = ""; public string ReasonLine = ""; public string DetailLine = ""; public string CaptionText = ""; public string JobLabel = ""; public float Score; public int Kills; public int Age; public int Level; public int Renown; public bool IsFavorite; public bool IsKing; public bool IsCityLeader; public bool IsFighting; public bool IsSpectacle; public bool IsLoneSpecies; public bool IsMale; public bool IsFemale; public string TaskText = ""; public string KingdomName = ""; public string CityName = ""; public readonly List ScoreReasons = new List(); public readonly List TopTraits = new List(); public sealed class TraitChip { public string Id = ""; public string Name = ""; public ActorTrait Trait; } /// Harness: force JobLabel on the next dossier build (no live citizen_job). public static string HarnessJobLabelOverride = ""; /// Harness: optional watch-reason fragment used with . public static string HarnessReasonOverride = ""; public static void ClearHarnessOverrides() { HarnessJobLabelOverride = ""; HarnessReasonOverride = ""; } /// /// Minimal dossier for archive subjects with no living unit (harness orphans / legacy deaths). /// public static UnitDossier FromFallenArchive( long unitId, string name, string speciesId, DeathManner manner = DeathManner.None) { UnitDossier d = new UnitDossier(); d.UnitId = unitId; d.Name = string.IsNullOrEmpty(name) ? "Nameless" : name; d.SpeciesId = string.IsNullOrEmpty(speciesId) ? "creature" : speciesId; d.Headline = string.IsNullOrEmpty(d.SpeciesId) ? d.Name : $"{d.Name} ({d.SpeciesId})"; string mannerLabel = Chronicle.DeathMannerLabel(manner); d.ReasonLine = string.IsNullOrEmpty(mannerLabel) || manner == DeathManner.None ? "Fallen" : char.ToUpperInvariant(mannerLabel[0]) + mannerLabel.Substring(1); d.DetailLine = ""; d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine); return d; } public static UnitDossier FromActor(Actor actor, InterestTier? watchTier = null, string watchLabel = null) { UnitDossier d = new UnitDossier(); if (actor == null || !actor.isAlive()) { d.Headline = "Nobody"; d.ReasonLine = ""; d.DetailLine = "no living unit"; d.CaptionText = d.Headline; return d; } Dictionary speciesCounts = WorldActivityScanner.CountSpeciesPopulations(); d.UnitId = actor.getID(); d.Name = SafeName(actor); d.SpeciesId = actor.asset != null ? actor.asset.id : "creature"; d.Kills = actor.data != null ? actor.data.kills : 0; d.Age = SafeAge(actor); d.Level = SafeLevel(actor); d.Renown = actor.renown; d.IsFavorite = actor.isFavorite(); d.IsKing = actor.isKing(); d.IsCityLeader = actor.isCityLeader(); d.IsFighting = actor.has_attack_target; d.IsSpectacle = WorldActivityScanner.IsSpectaclePublic(actor); d.IsLoneSpecies = WorldActivityScanner.IsSingletonSpeciesPublic(actor, speciesCounts); d.KingdomName = actor.kingdom != null ? actor.kingdom.name : ""; d.CityName = actor.city != null ? actor.city.name : ""; d.Score = WorldActivityScanner.ScoreActorPublic(actor, speciesCounts); FillSex(d, actor); d.TaskText = SafeTask(actor); FillTopTraits(d, actor); CollectReasons(d, actor, speciesCounts); d.JobLabel = ResolveJobLabel(actor); // Species icon is shown separately; keep id in the title for scanability. d.Headline = string.IsNullOrEmpty(d.SpeciesId) ? d.Name : $"{d.Name} ({d.SpeciesId})"; d.ReasonLine = BuildReasonLine(d, watchTier, watchLabel, actor, speciesCounts); d.DetailLine = BuildDetailLine(d); d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine); return d; } private static string ResolveJobLabel(Actor actor) { if (!string.IsNullOrEmpty(HarnessJobLabelOverride)) { return HarnessJobLabelOverride.Trim(); } string jobId = ActivityInterestTable.SafeJobId(actor); return ActivityProse.HumanizeJobPublic(jobId); } public bool ContainsIgnoreCase(string needle) { if (string.IsNullOrEmpty(needle)) { return false; } if ((CaptionText ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (Headline ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (ReasonLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (DetailLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (Name ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (SpeciesId ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (TaskText ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0) { return true; } // Harness probes "age" after we moved age onto an icon chip. if (needle.Equals("age", System.StringComparison.OrdinalIgnoreCase) && Age > 0) { return true; } for (int i = 0; i < TopTraits.Count; i++) { TraitChip chip = TopTraits[i]; if (chip == null) { continue; } if ((!string.IsNullOrEmpty(chip.Name) && chip.Name.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0) || (!string.IsNullOrEmpty(chip.Id) && chip.Id.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0)) { return true; } } return false; } private static string JoinCaption(string headline, string reason, string detail) { StringBuilder sb = new StringBuilder(); if (!string.IsNullOrEmpty(headline)) { sb.Append(headline); } if (!string.IsNullOrEmpty(reason)) { if (sb.Length > 0) { sb.Append('\n'); } sb.Append(reason); } if (!string.IsNullOrEmpty(detail)) { if (sb.Length > 0) { sb.Append('\n'); } sb.Append(detail); } return sb.ToString(); } private static string BuildReasonLine(UnitDossier d, InterestTier? watchTier, string watchLabel, Actor actor, Dictionary speciesCounts) { string tierPart = watchTier.HasValue ? watchTier.Value.ToString() : ""; string why = ""; if (!string.IsNullOrEmpty(HarnessReasonOverride)) { why = HarnessReasonOverride.Trim(); } else if (!string.IsNullOrEmpty(watchLabel)) { why = ShortenWatchLabel(watchLabel, d); } else { why = ShortenWatchLabel( WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts), d); // Ambient focus: FormatUnitLabel often equals the headline ("Name (species)"). if (IsRedundantWithHeadline(why, d)) { why = FirstFlavorTag(d); } } if (IsRedundantWithHeadline(why, d) || IsShownTraitName(d, why)) { why = ""; } string watchReason; if (string.IsNullOrEmpty(tierPart)) { watchReason = why ?? ""; } else if (string.IsNullOrEmpty(why) || why.IndexOf(tierPart, System.StringComparison.OrdinalIgnoreCase) >= 0) { watchReason = IsShownTraitName(d, tierPart) ? "" : tierPart; } else { watchReason = tierPart + " · " + why; } return ComposeReasonWithJob(watchReason, d.JobLabel); } /// Watch reason + job on the dossier row; job alone when reason is empty. private static string ComposeReasonWithJob(string watchReason, string jobLabel) { string reason = (watchReason ?? "").Trim(); string job = (jobLabel ?? "").Trim(); if (string.IsNullOrEmpty(job)) { return reason; } if (string.IsNullOrEmpty(reason)) { return job; } if (reason.Equals(job, System.StringComparison.OrdinalIgnoreCase) || reason.IndexOf(job, System.StringComparison.OrdinalIgnoreCase) >= 0) { return reason; } return reason + " · " + job; } private static string FirstFlavorTag(UnitDossier d) { for (int i = 0; i < d.ScoreReasons.Count; i++) { string reason = Humanize(d.ScoreReasons[i]); if (string.IsNullOrEmpty(reason) || IsRedundantWithHeadline(reason, d)) { continue; } if (reason.IndexOf("kills", System.StringComparison.OrdinalIgnoreCase) >= 0) { continue; } // Traits already render as icon chips; don't repeat them as the reason line. if (IsShownTraitName(d, reason)) { continue; } return reason; } return ""; } private static bool IsShownTraitName(UnitDossier d, string text) { if (d == null || string.IsNullOrEmpty(text)) { return false; } for (int i = 0; i < d.TopTraits.Count; i++) { TraitChip chip = d.TopTraits[i]; if (chip == null) { continue; } if ((!string.IsNullOrEmpty(chip.Name) && string.Equals(text, chip.Name, System.StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrEmpty(chip.Id) && string.Equals(text, Humanize(chip.Id), System.StringComparison.OrdinalIgnoreCase))) { return true; } } return false; } private static bool IsRedundantWithHeadline(string text, UnitDossier d) { if (string.IsNullOrEmpty(text) || d == null) { return true; } if (string.Equals(text, d.Headline, System.StringComparison.OrdinalIgnoreCase)) { return true; } if (string.Equals(text, d.Name, System.StringComparison.OrdinalIgnoreCase) || string.Equals(text, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)) { return true; } // "Name (species)" variants already covered by headline. if (!string.IsNullOrEmpty(d.Name) && !string.IsNullOrEmpty(d.SpeciesId) && text.IndexOf(d.Name, System.StringComparison.OrdinalIgnoreCase) >= 0 && text.IndexOf(d.SpeciesId, System.StringComparison.OrdinalIgnoreCase) >= 0) { return true; } return false; } private static string ShortenWatchLabel(string label, UnitDossier d) { if (string.IsNullOrEmpty(label)) { return ""; } string s = label.Trim(); if (s.StartsWith("New species:", System.StringComparison.OrdinalIgnoreCase)) { return "New species"; } if (s.StartsWith("Watching [", System.StringComparison.OrdinalIgnoreCase)) { int close = s.IndexOf(']'); if (close > 0 && close + 1 < s.Length) { s = s.Substring(close + 1).TrimStart(':', ' '); } } // Drop trailing "Name" duplicates like "Lone wolf: Waf" -> keep short form. int colon = s.IndexOf(':'); if (colon > 0 && colon < 28) { s = s.Substring(0, colon).Trim(); } if (d != null && IsRedundantWithHeadline(s, d)) { return ""; } if (s.Length > 36) { s = s.Substring(0, 33) + "..."; } return s; } private static void CollectReasons(UnitDossier d, Actor actor, Dictionary speciesCounts) { if (d.IsFavorite) { d.ScoreReasons.Add("favorite"); } if (d.IsKing) { d.ScoreReasons.Add("king"); } else if (d.IsCityLeader) { d.ScoreReasons.Add("leader"); } else if (actor.is_profession_warrior) { d.ScoreReasons.Add("warrior"); } if (d.IsFighting) { d.ScoreReasons.Add("fighting"); } if (d.Kills >= 100) { d.ScoreReasons.Add("100+ kills"); } else if (d.Kills >= 50) { d.ScoreReasons.Add("50+ kills"); } else if (d.Kills >= 10) { d.ScoreReasons.Add("10+ kills"); } if (d.IsLoneSpecies) { d.ScoreReasons.Add("lone of kind"); } if (d.IsSpectacle) { d.ScoreReasons.Add("spectacle"); } if (d.Renown >= 100) { d.ScoreReasons.Add("renown " + d.Renown); } // Trait names are shown as dossier chips; keep ScoreReasons for non-trait flavor only. } private static string BuildDetailLine(UnitDossier d) { // Compact text fallback for logs/harness: task + optional kingdom. StringBuilder sb = new StringBuilder(); if (!string.IsNullOrEmpty(d.TaskText)) { sb.Append(d.TaskText); } if (!string.IsNullOrEmpty(d.KingdomName) && !string.Equals(d.KingdomName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase) && !string.Equals(d.KingdomName, d.Name, System.StringComparison.OrdinalIgnoreCase)) { if (sb.Length > 0) { sb.Append(" · "); } sb.Append(d.KingdomName); } else if (!string.IsNullOrEmpty(d.CityName) && !string.Equals(d.CityName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase) && !string.Equals(d.CityName, d.Name, System.StringComparison.OrdinalIgnoreCase)) { if (sb.Length > 0) { sb.Append(" · "); } sb.Append(d.CityName); } return sb.ToString(); } private static void FillSex(UnitDossier d, Actor actor) { try { d.IsMale = actor.isSexMale(); d.IsFemale = actor.isSexFemale(); } catch { d.IsMale = false; d.IsFemale = false; } } private static void FillTopTraits(UnitDossier d, Actor actor) { d.TopTraits.Clear(); try { if (!actor.hasTraits()) { return; } // Pick the most interesting traits: rarity first, then non-default / dramatic. List ranked = new List(); foreach (ActorTrait trait in actor.getTraits()) { if (trait == null || string.IsNullOrEmpty(trait.id)) { continue; } ranked.Add(trait); } ranked.Sort((a, b) => TraitInterestScore(b, actor).CompareTo(TraitInterestScore(a, actor))); for (int i = 0; i < ranked.Count && d.TopTraits.Count < 3; i++) { ActorTrait trait = ranked[i]; d.TopTraits.Add(new TraitChip { Id = trait.id, Name = TraitDisplayName(trait), Trait = trait }); } } catch { // ignore trait access failures on exotic units } } /// /// Spectator-facing interest: Legendary/Epic over Normal, non-default over species baseline, /// Positive/Negative over bland Other. Ties break on rarer birth rates. /// private static int TraitInterestScore(ActorTrait trait, Actor actor) { if (trait == null) { return int.MinValue; } int score = 0; try { score += (int)trait.rarity * 100; } catch { // older builds without rarity } try { if (trait.type == TraitType.Negative) { score += 25; } else if (trait.type == TraitType.Positive) { score += 15; } } catch { // ignore } if (!IsDefaultTraitForActor(trait, actor)) { score += 50; } else { score -= 40; } try { // Low birth rate = unusual to spawn with; more interesting on a watch card. if (trait.rate_birth > 0 && trait.rate_birth <= 5) { score += 20; } else if (trait.rate_birth >= 40) { score -= 15; } } catch { // ignore } string id = trait.id ?? ""; if (id.IndexOf("miracle", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("immortal", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("zombie", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("blessed", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("cursed", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("evil", StringComparison.OrdinalIgnoreCase) >= 0) { score += 35; } return score; } private static bool IsDefaultTraitForActor(ActorTrait trait, Actor actor) { try { if (trait == null || actor == null || actor.asset == null || trait.default_for_actor_assets == null) { return false; } return trait.default_for_actor_assets.Contains(actor.asset); } catch { return false; } } private static string TraitDisplayName(ActorTrait trait) { try { string locale = trait.getLocaleID(); if (!string.IsNullOrEmpty(locale)) { string localized = LocalizedTextManager.getText(locale); if (!string.IsNullOrEmpty(localized) && localized != locale) { return localized; } } } catch { // fall through } return Humanize(trait.id); } private static string SafeTask(Actor actor) { try { if (!actor.hasTask() || actor.ai == null || actor.ai.task == null) { return ""; } string text = actor.ai.task.getLocalizedText(); if (!string.IsNullOrEmpty(text) && text != "-") { return text; } return Humanize(actor.ai.task.id); } catch { try { if (actor.hasTask() && actor.ai?.task != null) { return Humanize(actor.ai.task.id); } } catch { // ignore } return ""; } } private static string Humanize(string raw) { if (string.IsNullOrEmpty(raw)) { return ""; } return raw.Replace('_', ' '); } private static string SafeName(Actor actor) { try { string name = actor.getName(); if (!string.IsNullOrEmpty(name)) { return name; } } catch { // fall through } return actor.asset != null ? actor.asset.id : "unit"; } private static int SafeAge(Actor actor) { try { return actor.getAge(); } catch { return 0; } } private static int SafeLevel(Actor actor) { try { return actor.data != null ? actor.data.level : 0; } catch { return 0; } } }