993 lines
29 KiB
C#
993 lines
29 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Snapshot of why a living unit is interesting (caption + chronicle source).
|
|
/// </summary>
|
|
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<string> ScoreReasons = new List<string>();
|
|
public readonly List<TraitChip> TopTraits = new List<TraitChip>();
|
|
|
|
public sealed class TraitChip
|
|
{
|
|
public string Id = "";
|
|
public string Name = "";
|
|
public ActorTrait Trait;
|
|
}
|
|
|
|
/// <summary>Harness: force JobLabel on the next dossier build (no live citizen_job).</summary>
|
|
public static string HarnessJobLabelOverride = "";
|
|
|
|
/// <summary>Harness: optional watch-reason fragment used with <see cref="HarnessJobLabelOverride"/>.</summary>
|
|
public static string HarnessReasonOverride = "";
|
|
|
|
public static void ClearHarnessOverrides()
|
|
{
|
|
HarnessJobLabelOverride = "";
|
|
HarnessReasonOverride = "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal dossier for archive subjects with no living unit (harness orphans / legacy deaths).
|
|
/// </summary>
|
|
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<string, int> 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<string, int> speciesCounts)
|
|
{
|
|
string why = ResolveWatchWhy(d, watchLabel, actor, speciesCounts);
|
|
string badge = WatchBadge(watchTier, why);
|
|
|
|
string watchReason;
|
|
if (string.IsNullOrEmpty(badge))
|
|
{
|
|
watchReason = why ?? "";
|
|
}
|
|
else if (string.IsNullOrEmpty(why)
|
|
|| why.IndexOf(badge, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
watchReason = badge;
|
|
}
|
|
else
|
|
{
|
|
watchReason = badge + " · " + why;
|
|
}
|
|
|
|
return ComposeReasonWithJob(watchReason, d.JobLabel);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prefer a concrete event/activity phrase over identity ("King Name of X").
|
|
/// Nametag already shows who; this row should answer why the camera is here.
|
|
/// </summary>
|
|
private static string ResolveWatchWhy(
|
|
UnitDossier d,
|
|
string watchLabel,
|
|
Actor actor,
|
|
Dictionary<string, int> speciesCounts)
|
|
{
|
|
if (!string.IsNullOrEmpty(HarnessReasonOverride))
|
|
{
|
|
return HarnessReasonOverride.Trim();
|
|
}
|
|
|
|
// Live state beats a stale identity label (fighting king, burning favorite, …).
|
|
if (d.IsFighting)
|
|
{
|
|
return "Fighting";
|
|
}
|
|
|
|
if (d.IsSpectacle)
|
|
{
|
|
return "Spectacle";
|
|
}
|
|
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene != null
|
|
&& (scene.FollowUnit == actor
|
|
|| (!string.IsNullOrEmpty(scene.Label)
|
|
&& actor != null
|
|
&& scene.SubjectId == d.UnitId)))
|
|
{
|
|
string fromScene = WhyFromCandidate(scene, d);
|
|
if (!string.IsNullOrEmpty(fromScene))
|
|
{
|
|
return fromScene;
|
|
}
|
|
}
|
|
|
|
string fromLabel = ShortenWatchLabel(watchLabel, d);
|
|
if (!string.IsNullOrEmpty(fromLabel) && !IsIdentityWhy(fromLabel, d))
|
|
{
|
|
return fromLabel;
|
|
}
|
|
|
|
string flavor = FirstFlavorTag(d);
|
|
if (!string.IsNullOrEmpty(flavor))
|
|
{
|
|
return flavor;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(fromLabel))
|
|
{
|
|
return fromLabel;
|
|
}
|
|
|
|
string fallback = ShortenWatchLabel(
|
|
WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts), d);
|
|
if (IsRedundantWithHeadline(fallback, d) || IsIdentityWhy(fallback, d))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return fallback ?? "";
|
|
}
|
|
|
|
private static string WhyFromCandidate(InterestCandidate scene, UnitDossier d)
|
|
{
|
|
if (scene == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(scene.HappinessEffectId))
|
|
{
|
|
if (scene.HappinessEffectId.StartsWith("death_", System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
string grief = ShortenWatchLabel(scene.Label, d);
|
|
if (!string.IsNullOrEmpty(grief) && !IsIdentityWhy(grief, d))
|
|
{
|
|
return grief;
|
|
}
|
|
|
|
return "Grief";
|
|
}
|
|
|
|
string happy = ShortenWatchLabel(scene.Label, d);
|
|
if (!string.IsNullOrEmpty(happy) && !IsIdentityWhy(happy, d))
|
|
{
|
|
return happy;
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(scene.Verb)
|
|
&& scene.Verb.IndexOf("fight", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return "Fighting";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(scene.StatusId))
|
|
{
|
|
return Humanize(scene.StatusId);
|
|
}
|
|
|
|
string shortened = ShortenWatchLabel(scene.Label, d);
|
|
if (!string.IsNullOrEmpty(shortened) && !IsIdentityWhy(shortened, d))
|
|
{
|
|
return shortened;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(scene.Category)
|
|
&& !scene.Category.Equals("CharacterVignette", System.StringComparison.OrdinalIgnoreCase)
|
|
&& !scene.Category.Equals("Settlement", System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Humanize(scene.Category);
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
private static bool IsIdentityWhy(string text, UnitDossier d)
|
|
{
|
|
if (string.IsNullOrEmpty(text))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string t = text.Trim();
|
|
if (t.StartsWith("King ", System.StringComparison.OrdinalIgnoreCase)
|
|
|| t.StartsWith("King of ", System.StringComparison.OrdinalIgnoreCase)
|
|
|| t.StartsWith("Leader ", System.StringComparison.OrdinalIgnoreCase)
|
|
|| t.StartsWith("Leader of ", System.StringComparison.OrdinalIgnoreCase)
|
|
|| t.StartsWith("Favorite", System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Pure title leftovers after ShortenWatchLabel ("King", "Leader", "Favorite").
|
|
if (t.Equals("King", System.StringComparison.OrdinalIgnoreCase)
|
|
|| t.Equals("Leader", System.StringComparison.OrdinalIgnoreCase)
|
|
|| t.Equals("Favorite", System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
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)
|
|
{
|
|
// "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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft urgency badge. Skip when the why already carries the meaning (Fighting, War, …).
|
|
/// </summary>
|
|
private static string WatchBadge(InterestTier? watchTier, string why)
|
|
{
|
|
if (!watchTier.HasValue)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string whySafe = why ?? "";
|
|
bool whyIsEvent = whySafe.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| whySafe.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| whySafe.IndexOf("mourn", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| whySafe.IndexOf("Grief", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| whySafe.IndexOf("Spectacle", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| whySafe.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| whySafe.IndexOf("In action", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| whySafe.IndexOf("Busy", System.StringComparison.OrdinalIgnoreCase) >= 0;
|
|
|
|
switch (watchTier.Value)
|
|
{
|
|
case InterestTier.Epic:
|
|
return whyIsEvent && whySafe.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
? ""
|
|
: "World";
|
|
case InterestTier.Story:
|
|
return whyIsEvent ? "" : "Story";
|
|
case InterestTier.Action:
|
|
// "Action · …" was jargon; clear event phrases stand alone.
|
|
return whyIsEvent ? "" : "Live";
|
|
case InterestTier.Curiosity:
|
|
return whyIsEvent ? "" : "Curious";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/// <summary>Watch reason + job on the dossier row; job alone when reason is empty.</summary>
|
|
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)
|
|
{
|
|
// Prefer activity/event tags over identity (king/leader/favorite).
|
|
string[] prefer =
|
|
{
|
|
"fighting", "spectacle", "lone of kind", "warrior",
|
|
"100+ kills", "50+ kills", "10+ kills"
|
|
};
|
|
for (int p = 0; p < prefer.Length; p++)
|
|
{
|
|
for (int i = 0; i < d.ScoreReasons.Count; i++)
|
|
{
|
|
if (!string.Equals(d.ScoreReasons[i], prefer[p], System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string reason = Humanize(d.ScoreReasons[i]);
|
|
if (!string.IsNullOrEmpty(reason)
|
|
&& !IsRedundantWithHeadline(reason, d)
|
|
&& !IsShownTraitName(d, reason))
|
|
{
|
|
return reason;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Identity alone is weak when nametag already shows the unit.
|
|
if (reason.Equals("king", System.StringComparison.OrdinalIgnoreCase)
|
|
|| reason.Equals("leader", System.StringComparison.OrdinalIgnoreCase)
|
|
|| reason.Equals("favorite", System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (!string.IsNullOrEmpty(d.KingdomName)
|
|
&& reason.Equals("king", System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "King of " + d.KingdomName;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(d.CityName)
|
|
&& reason.Equals("leader", System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "Leader of " + d.CityName;
|
|
}
|
|
|
|
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<string, int> 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<ActorTrait> ranked = new List<ActorTrait>();
|
|
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
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spectator-facing interest: Legendary/Epic over Normal, non-default over species baseline,
|
|
/// Positive/Negative over bland Other. Ties break on rarer birth rates.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Live AI task label for the nametag chip (localized, else humanized id).</summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|