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 = ""; /// Nametag parenthesis: Species or Species/Job. public string IdentityTag = ""; 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 readonly List TopStatuses = new List(); public const int MaxTraitChips = 4; public const int MaxStatusChips = 4; public sealed class TraitChip { public string Id = ""; public string Name = ""; public ActorTrait Trait; } public sealed class StatusChip { public string Id = ""; public string Name = ""; public StatusAsset Status; } /// 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.IdentityTag = BuildIdentityTag(d.SpeciesId, jobLabel: ""); d.Headline = BuildHeadline(d.Name, d.IdentityTag); 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, 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); FillTopStatuses(d, actor); CollectReasons(d, actor, speciesCounts); d.JobLabel = ResolveJobLabel(actor); d.IdentityTag = BuildIdentityTag(d.SpeciesId, d.JobLabel); d.Headline = BuildHeadline(d.Name, d.IdentityTag); // Orange reason is story beat only; job lives in the nametag identity tag. d.ReasonLine = BuildStoryBeat(d, watchLabel, actor) ?? ""; 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)) { // Same discovery path as live jobs (resource-role families, Title Case id). return ActivityAssetCatalog.JobDisplayLabel(HarnessJobLabelOverride.Trim()); } try { if (actor?.citizen_job != null) { string fromAsset = ActivityAssetCatalog.JobDisplayLabel(actor.citizen_job); if (!string.IsNullOrEmpty(fromAsset)) { return fromAsset; } } } catch { // ignore } string jobId = ActivityInterestTable.SafeJobId(actor); return ActivityAssetCatalog.JobDisplayLabel(jobId); } /// /// Combined nametag tag: Species or Species/Job. /// public static string BuildIdentityTag(string speciesId, string jobLabel) { string species = ActivityAssetCatalog.SpeciesNametagLabel(speciesId); string job = ActivityAssetCatalog.TitleCaseWords(jobLabel ?? ""); if (string.IsNullOrEmpty(species)) { return job; } if (string.IsNullOrEmpty(job)) { return species; } if (species.Equals(job, StringComparison.OrdinalIgnoreCase)) { return species; } return species + "/" + job; } public static string BuildHeadline(string name, string identityTag) { string n = string.IsNullOrEmpty(name) ? "Nameless" : name.Trim(); string tag = (identityTag ?? "").Trim(); return string.IsNullOrEmpty(tag) ? n : n + " (" + tag + ")"; } /// Legacy helper: orange reason is story-only (job ignored). public static string ComposeReasonWithJob(string reason, string job) { return (reason ?? "").Trim(); } /// Public for live nametag refresh when citizen job changes. public static string ReadLiveJobLabel(Actor actor) { return ResolveJobLabel(actor); } /// Public for live dossier status chip refresh. public static void RefreshTopStatuses(UnitDossier dossier, Actor actor) { if (dossier == null) { return; } FillTopStatuses(dossier, actor); } 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 || (JobLabel ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (IdentityTag ?? "").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; } } for (int i = 0; i < TopStatuses.Count; i++) { StatusChip chip = TopStatuses[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(); } /// /// Orange dossier reason: tracked event that earned this shot. /// Empty when no owned active candidate (never invent from live fighting/job). /// private static string BuildStoryBeat(UnitDossier d, string watchLabel, Actor actor) { if (!string.IsNullOrEmpty(HarnessReasonOverride)) { return HarnessReasonOverride.Trim(); } return OwnedEventReason(actor, d); } /// /// Public so caption can refresh reason when the director leaves active dwell /// (quiet_grace) without rebuilding the whole dossier. /// public static string OwnedEventReason(Actor actor, UnitDossier dossier = null) { if (actor == null || !actor.isAlive()) { return ""; } InterestCandidate scene = InterestDirector.TryGetOwnedReasonCandidate(actor); if (scene == null) { return ""; } // Character-fill vignettes: hide reason (task chip shows live activity). if (scene.LeadKind == InterestLeadKind.CharacterLed && !InterestScoring.IsCombatAction(scene)) { return ""; } UnitDossier d = dossier; if (d == null) { d = new UnitDossier(); try { d.UnitId = actor.getID(); d.Name = SafeName(actor); d.SpeciesId = actor.asset != null ? actor.asset.id : ""; } catch { // ignore } } return EvaluateSceneLabel(scene, d, recordDrops: true) ?? ""; } /// /// Shared with - dossier orange reason rules. /// public static string EvaluateSceneLabel(InterestCandidate scene, bool recordDrops = true) { if (scene == null || string.IsNullOrEmpty(scene.Label)) { return ""; } UnitDossier d = new UnitDossier(); try { if (scene.FollowUnit != null && scene.FollowUnit.isAlive()) { d.UnitId = scene.FollowUnit.getID(); d.Name = SafeName(scene.FollowUnit); d.SpeciesId = scene.FollowUnit.asset != null ? scene.FollowUnit.asset.id : ""; } else if (!string.IsNullOrEmpty(scene.SpeciesId)) { d.SpeciesId = scene.SpeciesId; } } catch { // ignore } return EvaluateSceneLabel(scene, d, recordDrops); } private static string EvaluateSceneLabel(InterestCandidate scene, UnitDossier d, bool recordDrops) { return SceneLabelBeat(scene, d, recordDrops) ?? ""; } private static string SceneLabelBeat(InterestCandidate scene, UnitDossier d, bool recordDrops = true) { if (scene == null || string.IsNullOrEmpty(scene.Label)) { return ""; } string beat = EventLabelBeat(scene.Label, d, recordDrops); if (string.IsNullOrEmpty(beat)) { return ""; } // Ownership: never let the reason name a living stranger (not subject/related). if (ReasonNamesStranger(beat, scene)) { // Tiered combat tips name sides / duelists on purpose. if (!IsEnsembleCombatReason(beat)) { if (recordDrops) { InterestDropLog.Record("identity_filter", "stranger:" + beat); } return ""; } } // Ownership: reject beats that lead with someone else's proper name. if (LeadsWithForeignName(beat, scene)) { if (recordDrops) { InterestDropLog.Record("identity_filter", "foreign:" + beat); } return ""; } return beat; } private static bool LeadsWithForeignName(string reason, InterestCandidate scene) { if (string.IsNullOrEmpty(reason) || scene == null) { return false; } string first = FirstToken(reason); if (string.IsNullOrEmpty(first) || IsOwnedBeatKeyword(first)) { return false; } string subjectName = EventFeedUtil.SafeName(scene.FollowUnit); string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit); if (!string.IsNullOrEmpty(subjectName) && first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase)) { return false; } if (!string.IsNullOrEmpty(relatedName) && first.Equals(FirstToken(relatedName), System.StringComparison.OrdinalIgnoreCase)) { return false; } // Only reject when the leading token is another living unit's name. // Do not treat "Age" / "War" / catalog nouns as foreign proper names. if (World.world?.units == null || first.Length < 3) { return false; } try { foreach (Actor unit in World.world.units) { if (unit == null || !unit.isAlive()) { continue; } if (unit == scene.FollowUnit || unit == scene.RelatedUnit) { continue; } string name = EventFeedUtil.SafeName(unit); if (string.IsNullOrEmpty(name) || name.Length < 3) { continue; } if (first.Equals(FirstToken(name), System.StringComparison.OrdinalIgnoreCase)) { return true; } } } catch { // ignore } return false; } private static string FirstToken(string text) { if (string.IsNullOrEmpty(text)) { return ""; } int i = 0; while (i < text.Length && (char.IsWhiteSpace(text[i]) || text[i] == ':' || text[i] == '·')) { i++; } int start = i; while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_' || text[i] == '-')) { i++; } return start < i ? text.Substring(start, i - start) : ""; } private static bool IsOwnedBeatKeyword(string token) { if (string.IsNullOrEmpty(token)) { return true; } switch (token.ToLowerInvariant()) { case "fighting": case "war": case "battle": case "duel": case "skirmish": case "mass": case "plot": case "pack": case "outbreak": case "clash": case "grief": case "mourning": case "mourn": case "slain": case "lovers": case "lover": case "new": case "baby": case "book": case "building": case "boat": case "meta": case "seeking": case "joins": case "leaves": case "family": case "starts": case "gathers": case "gathering": case "damages": case "consumes": case "spectacle": case "in": return true; default: return false; } } private static bool IsEnsembleCombatReason(string reason) { if (string.IsNullOrEmpty(reason)) { return false; } return reason.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) || reason.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase) || reason.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) || reason.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) || reason.StartsWith("War -", StringComparison.OrdinalIgnoreCase) || reason.StartsWith("Plot -", StringComparison.OrdinalIgnoreCase) || reason.StartsWith("Pack -", StringComparison.OrdinalIgnoreCase) || reason.StartsWith("Outbreak -", StringComparison.OrdinalIgnoreCase) || reason.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0; } private static bool ReasonNamesStranger(string reason, InterestCandidate scene) { if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null) { return false; } string subjectName = EventFeedUtil.SafeName(scene.FollowUnit); string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit); try { foreach (Actor unit in World.world.units) { if (unit == null || !unit.isAlive()) { continue; } if (unit == scene.FollowUnit || unit == scene.RelatedUnit) { continue; } string name = EventFeedUtil.SafeName(unit); // Length 3 catches English crumbs in reasons ("egg", "war", "duel"). if (string.IsNullOrEmpty(name) || name.Length < 4) { continue; } if (!string.IsNullOrEmpty(subjectName) && name.Equals(subjectName, System.StringComparison.OrdinalIgnoreCase)) { continue; } if (!string.IsNullOrEmpty(relatedName) && name.Equals(relatedName, System.StringComparison.OrdinalIgnoreCase)) { continue; } if (ContainsWholeName(reason, name)) { return true; } } } catch { // ignore } return false; } /// True when appears as a whole token in . private static bool ContainsWholeName(string reason, string name) { if (string.IsNullOrEmpty(reason) || string.IsNullOrEmpty(name)) { return false; } int start = 0; while (start <= reason.Length - name.Length) { int idx = reason.IndexOf(name, start, System.StringComparison.OrdinalIgnoreCase); if (idx < 0) { return false; } bool leftOk = idx == 0 || !char.IsLetterOrDigit(reason[idx - 1]); int end = idx + name.Length; bool rightOk = end >= reason.Length || !char.IsLetterOrDigit(reason[end]); if (leftOk && rightOk) { return true; } start = idx + 1; } return false; } private static string EventLabelBeat(string label, UnitDossier d, bool recordDrops = true) { if (string.IsNullOrEmpty(label)) { return ""; } string s = label.Trim(); if (s.StartsWith("Watching [", System.StringComparison.OrdinalIgnoreCase)) { int close = s.IndexOf(']'); if (close > 0 && close + 1 < s.Length) { s = s.Substring(close + 1).TrimStart(':', ' '); } } // EventReason / Name-verb sentences are dossier-ready. if (LooksLikeEventSentence(s) || (!string.IsNullOrEmpty(d?.Name) && StartsWithSubjectVerbPhrase(s, d.Name))) { if (IsWeakFlavorBeat(s, d)) { if (recordDrops) { InterestDropLog.Record("identity_filter", "weak:" + s); } return ""; } return CleanBeat(s); } if (IsIdentityWhy(s, d) || IsWeakFlavorBeat(s, d)) { if (recordDrops) { InterestDropLog.Record("identity_filter", s); } return ""; } return CleanBeat(s); } private static bool IsWeakFlavorBeat(string text, UnitDossier d) { if (string.IsNullOrEmpty(text)) { return true; } string t = text.Trim(); if (t.StartsWith("New species", System.StringComparison.OrdinalIgnoreCase)) { return false; } if (t.StartsWith("Lone ", System.StringComparison.OrdinalIgnoreCase) || t.Equals("Lone of kind", System.StringComparison.OrdinalIgnoreCase) || t.Equals("lone of kind", System.StringComparison.OrdinalIgnoreCase)) { return true; } if (d != null && !string.IsNullOrEmpty(d.SpeciesId) && ContainsWholeName(t, d.SpeciesId) && t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) < 0 && t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) < 0 && t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0 && t.IndexOf(':') < 0 && !LooksLikeEventSentence(t)) { return true; } return false; } private static string CleanBeat(string text) { return string.IsNullOrEmpty(text) ? "" : text.Trim(); } private static bool IsIdentityWhy(string text, UnitDossier d) { if (string.IsNullOrEmpty(text)) { return true; } string t = text.Trim(); // Pure title crumbs only - not WorldLog beats like "King left:" / "King killed:". if (t.StartsWith("King of ", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Leader of ", System.StringComparison.OrdinalIgnoreCase) || t.Equals("King", System.StringComparison.OrdinalIgnoreCase) || t.Equals("Leader", System.StringComparison.OrdinalIgnoreCase) || t.Equals("Favorite", System.StringComparison.OrdinalIgnoreCase)) { return true; } // "Favorite …" title leftover without an event colon / fell beat. if (t.StartsWith("Favorite", System.StringComparison.OrdinalIgnoreCase) && t.IndexOf(':') < 0 && t.IndexOf("fell", System.StringComparison.OrdinalIgnoreCase) < 0) { return true; } if (d != null && !string.IsNullOrEmpty(d.Name) && t.IndexOf(d.Name, System.StringComparison.OrdinalIgnoreCase) >= 0 && t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) < 0 && t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) < 0 && t.IndexOf("mourn", System.StringComparison.OrdinalIgnoreCase) < 0 && t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0) { // EventReason sentences lead with the subject name - keep them. if (LooksLikeEventSentence(t) || StartsWithSubjectVerbPhrase(t, d.Name)) { return false; } // "Isemward (human, 12 kills)" style still useful via kills - keep if kills mentioned. if (t.IndexOf("kill", System.StringComparison.OrdinalIgnoreCase) >= 0) { return false; } return true; } return IsRedundantWithHeadline(t, d); } private static bool LooksLikeEventSentence(string text) { if (string.IsNullOrEmpty(text)) { return false; } string t = text; return t.IndexOf(" is ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" · ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" became ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" joins ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" leaves ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" forms ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" gains ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" loses ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" writes ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" founds ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" summons ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" begins ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" ends ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.EndsWith(" ends", System.StringComparison.OrdinalIgnoreCase) || t.IndexOf(" burns ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" welcomes ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" parted ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" decides ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" casts ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" forges ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" eating ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" foraging ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" damaging ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" plotting ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" lovers ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" hatches ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" mates ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" appears ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" finishes ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" bloodlust ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" killed", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" from ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" vs ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.StartsWith("Duel", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Skirmish", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Mass", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("War -", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Plot -", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Pack -", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Outbreak -", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("Rebellion:", System.StringComparison.OrdinalIgnoreCase) || t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase) || t.IndexOf(':') >= 0; } /// /// "Name verb …" EventReason form (covers hatches/mates/appears without an "is" helper). /// Also accepts authored middot crumbs ("Name · phrase") and possessives ("Name's family ends"). /// Rejects identity crumbs like "Name (species, …)". /// private static bool StartsWithSubjectVerbPhrase(string text, string name) { if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(name)) { return false; } if (!text.StartsWith(name, System.StringComparison.OrdinalIgnoreCase)) { return false; } int i = name.Length; if (i >= text.Length) { return false; } // "Name · library / happiness crumb" if (text[i] == ' ' && i + 1 < text.Length && (text[i + 1] == '·' || text[i + 1] == '•')) { return true; } // "Name's family ends" if (text[i] == '\'' && i + 2 < text.Length && (text[i + 1] == 's' || text[i + 1] == 'S') && text[i + 2] == ' ') { int afterPossessive = i + 3; return afterPossessive < text.Length && char.IsLetter(text[afterPossessive]) && text[afterPossessive] != '('; } if (text[i] != ' ') { return false; } int verbStart = i + 1; if (verbStart >= text.Length) { return false; } char c = text[verbStart]; return char.IsLetter(c) && c != '('; } 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 ""; } 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(LiveEnsemble.KingdomDisplay(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 < MaxTraitChips; 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 } } private static void FillTopStatuses(UnitDossier d, Actor actor) { d.TopStatuses.Clear(); if (actor == null) { return; } try { var ids = actor.getStatusesIds(); if (ids == null) { return; } List ranked = new List(); foreach (string rawId in ids) { if (string.IsNullOrEmpty(rawId)) { continue; } string id = rawId.Trim(); StatusAsset asset = ActivityAssetCatalog.TryGetStatusAsset(id); ranked.Add(new StatusChip { Id = id, Name = StatusDisplayName(id, asset), Status = asset }); } ranked.Sort((a, b) => StatusInterestScore(b).CompareTo(StatusInterestScore(a))); for (int i = 0; i < ranked.Count && d.TopStatuses.Count < MaxStatusChips; i++) { d.TopStatuses.Add(ranked[i]); } } catch { // ignore status access failures on exotic units } } /// /// Spectator-facing status priority: authored strength / camera, lasting afflictions, /// demote brief combat FX. Scales to unknown live ids via heuristics. /// private static int StatusInterestScore(StatusChip chip) { if (chip == null || string.IsNullOrEmpty(chip.Id)) { return int.MinValue; } string id = chip.Id; int score = 0; StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(id); if (entry != null && !entry.IsFallback) { score += MathfRound(entry.EventStrength); if (entry.CreatesInterest) { score += 40; } string cat = entry.Category ?? ""; if (cat.Equals("StatusTransformation", StringComparison.OrdinalIgnoreCase) || cat.Equals("Grief", StringComparison.OrdinalIgnoreCase) || cat.Equals("LifeChapter", StringComparison.OrdinalIgnoreCase)) { score += 30; } else if (cat.Equals("StatusAmbient", StringComparison.OrdinalIgnoreCase) || cat.Equals("Emotion", StringComparison.OrdinalIgnoreCase)) { score += 5; } } else { score += 20; } if (StatusOutbreakFeed.LooksLikeAfflictionCluster(id)) { score += 45; } if (StatusOutbreakFeed.IsBriefCombatOrSpellFx(id)) { score -= 80; } try { StatusAsset asset = chip.Status ?? ActivityAssetCatalog.TryGetStatusAsset(id); if (asset != null && asset.duration > 0f) { if (asset.duration <= 6f) { score -= 35; } else if (asset.duration >= 30f) { score += 20; } else { score += 8; } } } catch { // ignore } return score; } private static int MathfRound(float value) { return (int)(value + (value >= 0f ? 0.5f : -0.5f)); } private static string StatusDisplayName(string statusId, StatusAsset asset) { try { if (asset != null) { string locale = asset.getLocaleID(); if (!string.IsNullOrEmpty(locale)) { string localized = LocalizedTextManager.getText(locale); if (!string.IsNullOrEmpty(localized) && !localized.Equals(locale, StringComparison.Ordinal) && localized.IndexOf('_') < 0) { return ActivityAssetCatalog.TitleCaseWords(localized.Trim()); } } } } catch { // fall through } string prose = ActivityStatusProse.StatusLabelFromId(statusId); return ActivityAssetCatalog.TitleCaseWords( string.IsNullOrEmpty(prose) ? statusId : prose); } /// /// 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); } /// Live AI task label for the nametag chip (localized, else humanized id). public static string ReadLiveTask(Actor actor) { return SafeTask(actor); } private static string SafeTask(Actor actor) { try { if (actor == null || !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 != null && 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; } } }