- Add Dossier/Saga tabs, Design A panel, and hover preview with read-pause - Persist observed saga facts across roster churn; pin subject without stale fallback - Restore Saga after Open Lore; hide redundant Kind · Climax spine under matching tips - Densify dossier header/chips and keep the cast rail as stable top chrome - Extend harness coverage for hover restore, empty pick, Evidence, and Lore return
1836 lines
57 KiB
C#
1836 lines
57 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 = "";
|
|
/// <summary>Other unit named by the orange reason (0 when none).</summary>
|
|
public long ReasonRelatedId;
|
|
public string DetailLine = "";
|
|
public string CaptionText = "";
|
|
public string JobLabel = "";
|
|
/// <summary>Nametag parenthesis: <c>Species</c> or <c>Species/Job</c>.</summary>
|
|
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<string> ScoreReasons = new List<string>();
|
|
public readonly List<TraitChip> TopTraits = new List<TraitChip>();
|
|
public readonly List<StatusChip> TopStatuses = new List<StatusChip>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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 = "";
|
|
|
|
/// <summary>Harness: related unit for forced reason clicks / gold names.</summary>
|
|
public static long HarnessReasonRelatedId;
|
|
|
|
/// <summary>Harness: related display name for forced reason prose.</summary>
|
|
public static string HarnessReasonRelatedName = "";
|
|
|
|
public static void ClearHarnessOverrides()
|
|
{
|
|
HarnessJobLabelOverride = "";
|
|
HarnessReasonOverride = "";
|
|
HarnessReasonRelatedId = 0;
|
|
HarnessReasonRelatedName = "";
|
|
}
|
|
|
|
/// <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.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"
|
|
: mannerLabel;
|
|
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<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);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Combined nametag tag: <c>Species</c> or <c>Species/Job</c>.
|
|
/// </summary>
|
|
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 + ")";
|
|
}
|
|
|
|
/// <summary>Legacy helper: orange reason is story-only (job ignored).</summary>
|
|
public static string ComposeReasonWithJob(string reason, string job)
|
|
{
|
|
return (reason ?? "").Trim();
|
|
}
|
|
|
|
/// <summary>Public for live nametag refresh when citizen job changes.</summary>
|
|
public static string ReadLiveJobLabel(Actor actor)
|
|
{
|
|
return ResolveJobLabel(actor);
|
|
}
|
|
|
|
/// <summary>Public for live dossier status chip refresh.</summary>
|
|
public static void RefreshTopStatuses(UnitDossier dossier, Actor actor)
|
|
{
|
|
if (dossier == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
FillTopStatuses(dossier, actor);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Highest-value live status using the same scalable ranking as dossier chips.
|
|
/// </summary>
|
|
public static string ReadTopStatus(Actor actor)
|
|
{
|
|
var scratch = new UnitDossier();
|
|
FillTopStatuses(scratch, actor);
|
|
return scratch.TopStatuses.Count > 0
|
|
? scratch.TopStatuses[0]?.Name ?? ""
|
|
: "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interesting, non-species-default traits using the dossier's live trait ranking.
|
|
/// </summary>
|
|
public static List<string> ReadStandoutTraitNames(Actor actor, int max, int minScore = 80)
|
|
{
|
|
var names = new List<string>(Math.Max(0, max));
|
|
if (actor == null || max <= 0)
|
|
{
|
|
return names;
|
|
}
|
|
|
|
try
|
|
{
|
|
var ranked = new List<ActorTrait>();
|
|
if (!actor.hasTraits())
|
|
{
|
|
return names;
|
|
}
|
|
|
|
foreach (ActorTrait trait in actor.getTraits())
|
|
{
|
|
if (trait == null
|
|
|| string.IsNullOrEmpty(trait.id)
|
|
|| IsDefaultTraitForActor(trait, actor)
|
|
|| TraitInterestScore(trait, actor) < minScore)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ranked.Add(trait);
|
|
}
|
|
|
|
ranked.Sort((a, b) => TraitInterestScore(b, actor).CompareTo(TraitInterestScore(a, actor)));
|
|
for (int i = 0; i < ranked.Count && names.Count < max; i++)
|
|
{
|
|
string name = TraitDisplayName(ranked[i]);
|
|
if (!string.IsNullOrEmpty(name) && !names.Contains(name))
|
|
{
|
|
names.Add(name);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Exotic actors may not expose a normal trait collection.
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Orange dossier reason: tracked event that earned this shot.
|
|
/// Empty when no owned active candidate (never invent from live fighting/job).
|
|
/// </summary>
|
|
private static string BuildStoryBeat(UnitDossier d, string watchLabel, Actor actor)
|
|
{
|
|
if (!string.IsNullOrEmpty(HarnessReasonOverride))
|
|
{
|
|
string plain = HarnessReasonOverride.Trim();
|
|
string subject = d != null ? (d.Name ?? "") : "";
|
|
if (string.IsNullOrEmpty(subject) && actor != null)
|
|
{
|
|
subject = EventFeedUtil.SafeName(actor);
|
|
}
|
|
|
|
string relatedName = HarnessReasonRelatedName ?? "";
|
|
if (d != null)
|
|
{
|
|
d.ReasonRelatedId = ResolveReasonRelatedId(
|
|
actor,
|
|
HarnessReasonRelatedId,
|
|
relatedName);
|
|
}
|
|
|
|
return ActivityProse.ColorizePersonNames(plain, subject, relatedName);
|
|
}
|
|
|
|
return OwnedEventReason(actor, d);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Public so caption can refresh reason when the director leaves active dwell
|
|
/// (quiet_grace) without rebuilding the whole dossier.
|
|
/// </summary>
|
|
public static string OwnedEventReason(Actor actor, UnitDossier dossier = null)
|
|
{
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
return "";
|
|
}
|
|
|
|
InterestCandidate scene = InterestDirector.TryGetOwnedReasonCandidate(actor);
|
|
if (scene == null)
|
|
{
|
|
if (dossier != null)
|
|
{
|
|
dossier.ReasonRelatedId = 0;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
// Character-fill vignettes: hide reason (task chip shows live activity).
|
|
if (scene.LeadKind == InterestLeadKind.CharacterLed
|
|
&& !InterestScoring.IsCombatAction(scene))
|
|
{
|
|
if (dossier != null)
|
|
{
|
|
dossier.ReasonRelatedId = 0;
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
string beat = EvaluateSceneLabel(scene, d, recordDrops: true) ?? "";
|
|
if (string.IsNullOrEmpty(beat))
|
|
{
|
|
d.ReasonRelatedId = 0;
|
|
return "";
|
|
}
|
|
|
|
// Dossier subject must own the leading proper name on non-ensemble tips.
|
|
// Catches Follow/Subject drift where the tip Label still names someone else.
|
|
if (!IsEnsembleCombatReason(beat) && ReasonLeadsWithForeignDossierName(beat, actor, d, scene))
|
|
{
|
|
InterestDropLog.Record("identity_filter", "dossier_foreign:" + beat);
|
|
d.ReasonRelatedId = 0;
|
|
return "";
|
|
}
|
|
|
|
// Gold names inside the orange reason row (same palette as history/activity).
|
|
string subjectName = d != null ? (d.Name ?? "") : "";
|
|
if (string.IsNullOrEmpty(subjectName))
|
|
{
|
|
subjectName = EventFeedUtil.SafeName(actor);
|
|
}
|
|
|
|
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
|
|
long relatedId = scene.RelatedId != 0
|
|
? scene.RelatedId
|
|
: EventFeedUtil.SafeId(scene.RelatedUnit);
|
|
d.ReasonRelatedId = ResolveReasonRelatedId(actor, relatedId, relatedName);
|
|
return ActivityProse.ColorizePersonNames(beat, subjectName, relatedName);
|
|
}
|
|
|
|
private static long ResolveReasonRelatedId(Actor subject, long relatedId, string relatedName)
|
|
{
|
|
long subjectId = 0;
|
|
try
|
|
{
|
|
subjectId = subject != null ? subject.getID() : 0;
|
|
}
|
|
catch
|
|
{
|
|
subjectId = 0;
|
|
}
|
|
|
|
if (relatedId != 0 && relatedId != subjectId)
|
|
{
|
|
return relatedId;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(relatedName))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
return ActivityLog.ResolveLivingUnitIdByName(relatedName, subjectId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared with <see cref="EventPresentability"/> - dossier orange reason rules.
|
|
/// </summary>
|
|
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);
|
|
if (string.IsNullOrEmpty(subjectName))
|
|
{
|
|
Actor subject = EventFeedUtil.FindAliveById(scene.SubjectId);
|
|
subjectName = EventFeedUtil.SafeName(subject);
|
|
}
|
|
|
|
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
|
|
if (string.IsNullOrEmpty(relatedName) && scene.RelatedId != 0)
|
|
{
|
|
relatedName = EventFeedUtil.SafeName(EventFeedUtil.FindAliveById(scene.RelatedId));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
long id = EventFeedUtil.SafeId(unit);
|
|
if (unit == scene.FollowUnit
|
|
|| unit == scene.RelatedUnit
|
|
|| (scene.SubjectId != 0 && id == scene.SubjectId)
|
|
|| (scene.RelatedId != 0 && id == scene.RelatedId))
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the reason leads with a living unit name that is not the dossier subject
|
|
/// (and not the scene's related partner when the dossier subject is that partner).
|
|
/// </summary>
|
|
private static bool ReasonLeadsWithForeignDossierName(
|
|
string reason,
|
|
Actor dossierActor,
|
|
UnitDossier dossier,
|
|
InterestCandidate scene)
|
|
{
|
|
if (string.IsNullOrEmpty(reason) || scene == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string first = FirstToken(reason);
|
|
if (string.IsNullOrEmpty(first) || IsOwnedBeatKeyword(first))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string dossierName = dossier != null ? (dossier.Name ?? "") : "";
|
|
if (string.IsNullOrEmpty(dossierName))
|
|
{
|
|
dossierName = EventFeedUtil.SafeName(dossierActor);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(dossierName)
|
|
&& first.Equals(FirstToken(dossierName), System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Related partner may show a subject-led sentence ("Iksssssa shares intimacy with Anya")
|
|
// only when the dossier subject is that related partner.
|
|
long dossierId = dossier != null ? dossier.UnitId : EventFeedUtil.SafeId(dossierActor);
|
|
if (dossierId != 0
|
|
&& scene.RelatedId != 0
|
|
&& dossierId == scene.RelatedId)
|
|
{
|
|
Actor subject = scene.FollowUnit != null && scene.FollowUnit.isAlive()
|
|
? scene.FollowUnit
|
|
: EventFeedUtil.FindAliveById(scene.SubjectId);
|
|
string subjectName = EventFeedUtil.SafeName(subject);
|
|
if (!string.IsNullOrEmpty(subjectName)
|
|
&& first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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 == dossierActor)
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <summary>True when <paramref name="name"/> appears as a whole token in <paramref name="reason"/>.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// "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, …)".
|
|
/// </summary>
|
|
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<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.
|
|
// Story/crisis FixedDwell reasons own the orange line - do not append combat
|
|
// task crumbs that read like a stale "Fighting · Kingdom" spine.
|
|
StringBuilder sb = new StringBuilder();
|
|
InterestCandidate watch = InterestDirector.CurrentCandidate;
|
|
bool suppressCombatTask = StoryPlanner.IsStoryOwnedTip(watch)
|
|
|| (!string.IsNullOrEmpty(d.ReasonLine)
|
|
&& (d.ReasonLine.IndexOf("surveys the aftermath",
|
|
System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| d.ReasonLine.IndexOf("stands over",
|
|
System.StringComparison.OrdinalIgnoreCase) >= 0));
|
|
if (!string.IsNullOrEmpty(d.TaskText)
|
|
&& !(suppressCombatTask && IsCombatishTask(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 bool IsCombatishTask(string task)
|
|
{
|
|
if (string.IsNullOrEmpty(task))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string t = task.Trim();
|
|
return t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| t.IndexOf("Attack", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| t.IndexOf("Battle", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| t.IndexOf("Hunt", System.StringComparison.OrdinalIgnoreCase) >= 0;
|
|
}
|
|
|
|
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 < 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<StatusChip> ranked = new List<StatusChip>();
|
|
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
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spectator-facing status priority: authored strength / camera, lasting afflictions,
|
|
/// demote brief combat FX. Scales to unknown live ids via heuristics.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|