- Cap ParentChild, skip Cast-echo kin, and summarize large lineages - Prefer war/bond/role beats with dated prose over raw "Child X" lines - Gate with saga_legacy_quality (3/3) and document the Legacy contract
895 lines
27 KiB
C#
895 lines
27 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Adaptive saga presentation model. Consumes live actor state + LifeSagaMemory.
|
|
/// No task/status/trait/Chronicle dumps.
|
|
/// </summary>
|
|
public static class LifeSagaPresentation
|
|
{
|
|
private static bool _heirProbeDone;
|
|
private static bool _heirApiAvailable;
|
|
private static MethodInfo _heirMethod;
|
|
private static object _heirTargetSample;
|
|
|
|
public static LifeSagaViewModel Build(long unitId)
|
|
{
|
|
var model = new LifeSagaViewModel { UnitId = unitId };
|
|
if (unitId == 0)
|
|
{
|
|
return model;
|
|
}
|
|
|
|
LifeSagaSlot slot = LifeSagaRoster.Get(unitId);
|
|
Actor actor = EventFeedUtil.FindAliveById(unitId);
|
|
LifeSagaLifeMemory memory = LifeSagaMemory.Get(unitId);
|
|
|
|
string name = FirstNonEmpty(
|
|
slot?.DisplayName,
|
|
memory?.Identity.Name,
|
|
SafeName(actor),
|
|
"Someone");
|
|
model.Name = name;
|
|
if (slot != null && (slot.Prefer || slot.GameFavorite))
|
|
{
|
|
model.Name = "★ " + name;
|
|
}
|
|
|
|
model.Title = BuildTitle(slot, actor, memory);
|
|
model.SpeciesId = FirstNonEmpty(
|
|
slot?.SpeciesId,
|
|
memory?.Identity.SpeciesId,
|
|
actor != null && actor.asset != null ? actor.asset.id : "");
|
|
ResolveLenses(slot, actor, memory, out LifeSagaLens primary, out LifeSagaLens secondary);
|
|
model.PrimaryLens = primary;
|
|
model.SecondaryLens = secondary;
|
|
|
|
LifeSagaFact stake = LifeSagaMemory.StrongestUnresolved(unitId);
|
|
model.StakeLine = stake != null
|
|
? LifeSagaMemory.FormatFactLine(stake)
|
|
: BuildFallbackStake(slot, actor, memory);
|
|
model.StakeProvenance = stake != null ? "observed" : (string.IsNullOrEmpty(model.StakeLine) ? "" : "is");
|
|
|
|
BuildCast(model, slot, actor, memory, primary);
|
|
model.LensEyebrow = FormatLensEyebrow(primary, secondary);
|
|
BuildLegacy(model, unitId, stake);
|
|
return model;
|
|
}
|
|
|
|
/// <summary>Compatibility plain text for harness diagnostics.</summary>
|
|
public static string BuildPlainText(long unitId)
|
|
{
|
|
return Build(unitId).ToPlainText();
|
|
}
|
|
|
|
public static string BuildPlainText(LifeSagaSlot slot)
|
|
{
|
|
return Build(slot?.UnitId ?? 0).ToPlainText();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Live probe: only report an heir when a game-authored succession API is discoverable.
|
|
/// </summary>
|
|
public static bool TryGetLikelySuccessor(Actor actor, out Actor heir, out string evidence)
|
|
{
|
|
heir = null;
|
|
evidence = "";
|
|
EnsureHeirProbe();
|
|
if (!_heirApiAvailable || actor == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
object clan = actor.GetType().GetProperty("clan")?.GetValue(actor, null)
|
|
?? actor.GetType().GetField("clan")?.GetValue(actor);
|
|
if (clan == null || _heirMethod == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
object result = null;
|
|
if (_heirMethod.GetParameters().Length == 0)
|
|
{
|
|
result = _heirMethod.Invoke(clan, null);
|
|
}
|
|
else if (_heirMethod.GetParameters().Length == 1)
|
|
{
|
|
result = _heirMethod.Invoke(clan, new object[] { actor });
|
|
}
|
|
|
|
heir = result as Actor;
|
|
if (heir != null && heir.isAlive() && heir != actor)
|
|
{
|
|
evidence = "clan_succession";
|
|
return true;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// omit rather than guess
|
|
}
|
|
|
|
heir = null;
|
|
return false;
|
|
}
|
|
|
|
public static bool HeirApiAvailable
|
|
{
|
|
get
|
|
{
|
|
EnsureHeirProbe();
|
|
return _heirApiAvailable;
|
|
}
|
|
}
|
|
|
|
private static void EnsureHeirProbe()
|
|
{
|
|
if (_heirProbeDone)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_heirProbeDone = true;
|
|
try
|
|
{
|
|
Type clanType = typeof(Actor).Assembly.GetType("Clan")
|
|
?? Type.GetType("Clan, Assembly-CSharp");
|
|
if (clanType == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string[] names =
|
|
{
|
|
"findNextHeir", "getNextHeir", "getHeir", "findHeir", "calculateHeir", "getSuccessor"
|
|
};
|
|
for (int i = 0; i < names.Length; i++)
|
|
{
|
|
MethodInfo m = clanType.GetMethod(
|
|
names[i],
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
if (m != null && (typeof(Actor).IsAssignableFrom(m.ReturnType) || m.ReturnType == typeof(object)))
|
|
{
|
|
_heirMethod = m;
|
|
_heirApiAvailable = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Culture succession helpers.
|
|
Type cultureType = typeof(Actor).Assembly.GetType("Culture");
|
|
if (cultureType != null)
|
|
{
|
|
MethodInfo m = cultureType.GetMethod(
|
|
"findNextHeir",
|
|
BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
|
if (m != null)
|
|
{
|
|
_heirMethod = m;
|
|
_heirApiAvailable = true;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
_heirApiAvailable = false;
|
|
}
|
|
|
|
_ = _heirTargetSample;
|
|
}
|
|
|
|
private static void ResolveLenses(
|
|
LifeSagaSlot slot,
|
|
Actor actor,
|
|
LifeSagaLifeMemory memory,
|
|
out LifeSagaLens primary,
|
|
out LifeSagaLens secondary)
|
|
{
|
|
var scores = new Dictionary<LifeSagaLens, float>();
|
|
void Add(LifeSagaLens lens, float amount)
|
|
{
|
|
if (lens == LifeSagaLens.None || amount <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!scores.TryGetValue(lens, out float cur))
|
|
{
|
|
cur = 0f;
|
|
}
|
|
|
|
scores[lens] = cur + amount;
|
|
}
|
|
|
|
if (slot != null)
|
|
{
|
|
if (slot.IsKing || slot.IsClanChief || slot.IsLeader)
|
|
{
|
|
Add(LifeSagaLens.CrownClan, 40f);
|
|
}
|
|
|
|
if (slot.IsAlpha)
|
|
{
|
|
Add(LifeSagaLens.PackAlpha, 38f);
|
|
}
|
|
|
|
if (slot.IsPlotAuthor)
|
|
{
|
|
Add(LifeSagaLens.Plotter, 36f);
|
|
}
|
|
|
|
if (slot.IsFounder)
|
|
{
|
|
Add(LifeSagaLens.FounderWanderer, 30f);
|
|
}
|
|
|
|
if (slot.IsSingleton)
|
|
{
|
|
Add(LifeSagaLens.LastOfKind, 34f);
|
|
}
|
|
|
|
if (slot.Kills > 0 || slot.IsArmyCaptain)
|
|
{
|
|
Add(LifeSagaLens.WarriorKiller, Mathf.Min(35f, 12f + slot.Kills * 0.4f));
|
|
}
|
|
|
|
switch (slot.AdmissionReason)
|
|
{
|
|
case LifeSagaAdmissionReason.King:
|
|
case LifeSagaAdmissionReason.ClanChief:
|
|
case LifeSagaAdmissionReason.CityLeader:
|
|
Add(LifeSagaLens.CrownClan, 8f);
|
|
break;
|
|
case LifeSagaAdmissionReason.Alpha:
|
|
Add(LifeSagaLens.PackAlpha, 8f);
|
|
break;
|
|
case LifeSagaAdmissionReason.PlotAuthor:
|
|
Add(LifeSagaLens.Plotter, 8f);
|
|
break;
|
|
case LifeSagaAdmissionReason.Founder:
|
|
Add(LifeSagaLens.FounderWanderer, 8f);
|
|
break;
|
|
case LifeSagaAdmissionReason.Singleton:
|
|
Add(LifeSagaLens.LastOfKind, 8f);
|
|
break;
|
|
case LifeSagaAdmissionReason.NotableKills:
|
|
Add(LifeSagaLens.WarriorKiller, 8f);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (memory != null)
|
|
{
|
|
for (int i = 0; i < memory.Facts.Count; i++)
|
|
{
|
|
LifeSagaFact f = memory.Facts[i];
|
|
if (f == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
switch (f.Kind)
|
|
{
|
|
case LifeSagaFactKind.BondFormed:
|
|
case LifeSagaFactKind.BondBroken:
|
|
case LifeSagaFactKind.HardArcLove:
|
|
case LifeSagaFactKind.HardArcGrief:
|
|
Add(LifeSagaLens.LoveGrief, 12f);
|
|
break;
|
|
case LifeSagaFactKind.Kill:
|
|
case LifeSagaFactKind.RivalEarned:
|
|
case LifeSagaFactKind.HardArcCombat:
|
|
Add(LifeSagaLens.WarriorKiller, 14f);
|
|
break;
|
|
case LifeSagaFactKind.WarJoin:
|
|
case LifeSagaFactKind.HardArcWar:
|
|
Add(LifeSagaLens.CrownClan, 10f);
|
|
Add(LifeSagaLens.WarriorKiller, 8f);
|
|
break;
|
|
case LifeSagaFactKind.PlotJoin:
|
|
case LifeSagaFactKind.HardArcPlot:
|
|
Add(LifeSagaLens.Plotter, 16f);
|
|
break;
|
|
case LifeSagaFactKind.Founding:
|
|
case LifeSagaFactKind.HomeGained:
|
|
case LifeSagaFactKind.HomeLost:
|
|
Add(LifeSagaLens.FounderWanderer, 12f);
|
|
break;
|
|
case LifeSagaFactKind.RoleChange:
|
|
if ((f.Note ?? "").IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
Add(LifeSagaLens.PackAlpha, 14f);
|
|
}
|
|
else
|
|
{
|
|
Add(LifeSagaLens.CrownClan, 12f);
|
|
}
|
|
|
|
break;
|
|
case LifeSagaFactKind.ParentChild:
|
|
Add(LifeSagaLens.PackAlpha, 6f);
|
|
Add(LifeSagaLens.LoveGrief, 4f);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (actor != null && LifeSagaRoster.IsPackAlpha(actor))
|
|
{
|
|
Add(LifeSagaLens.PackAlpha, 10f);
|
|
}
|
|
|
|
primary = LifeSagaLens.None;
|
|
secondary = LifeSagaLens.None;
|
|
if (scores.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float best = -1f;
|
|
float second = -1f;
|
|
foreach (KeyValuePair<LifeSagaLens, float> kv in scores)
|
|
{
|
|
if (kv.Value > best)
|
|
{
|
|
second = best;
|
|
secondary = primary;
|
|
best = kv.Value;
|
|
primary = kv.Key;
|
|
}
|
|
else if (kv.Value > second)
|
|
{
|
|
second = kv.Value;
|
|
secondary = kv.Key;
|
|
}
|
|
}
|
|
|
|
if (secondary == primary || second < best * 0.45f)
|
|
{
|
|
secondary = LifeSagaLens.None;
|
|
}
|
|
}
|
|
|
|
private static string BuildTitle(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
|
|
{
|
|
if (slot != null)
|
|
{
|
|
if (slot.IsKing)
|
|
{
|
|
string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
|
|
return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom;
|
|
}
|
|
|
|
if (slot.IsClanChief) return "Clan chief";
|
|
if (slot.IsLeader)
|
|
{
|
|
string city = FirstNonEmpty(slot.CityKey, CityName(actor));
|
|
return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city;
|
|
}
|
|
|
|
if (slot.IsAlpha) return "Pack alpha";
|
|
if (slot.IsArmyCaptain) return "Army captain";
|
|
if (slot.IsFounder) return "Founder";
|
|
if (slot.IsPlotAuthor) return "Plotter";
|
|
if (slot.IsSingleton) return "Last of their kind";
|
|
}
|
|
|
|
if (memory != null)
|
|
{
|
|
for (int i = 0; i < memory.Facts.Count; i++)
|
|
{
|
|
if (memory.Facts[i]?.Kind == LifeSagaFactKind.Founding)
|
|
{
|
|
return "Founder";
|
|
}
|
|
}
|
|
}
|
|
|
|
return "A life in motion";
|
|
}
|
|
|
|
private static string BuildFallbackStake(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
|
|
{
|
|
if (LifeSagaMemory.TryGetEarnedRival(slot?.UnitId ?? 0, out LifeSagaFact rival))
|
|
{
|
|
return LifeSagaMemory.FormatFactLine(rival);
|
|
}
|
|
|
|
if (slot != null)
|
|
{
|
|
switch (slot.AdmissionReason)
|
|
{
|
|
case LifeSagaAdmissionReason.King:
|
|
return "Carries a kingdom's fate";
|
|
case LifeSagaAdmissionReason.Alpha:
|
|
return "Holds the pack together";
|
|
case LifeSagaAdmissionReason.PlotAuthor:
|
|
return "A scheme is still unfolding";
|
|
case LifeSagaAdmissionReason.Singleton:
|
|
return "Their lineage hangs by a thread";
|
|
case LifeSagaAdmissionReason.Founder:
|
|
return "What they built still needs defending";
|
|
}
|
|
}
|
|
|
|
if (actor != null && TryGetLikelySuccessor(actor, out Actor heir, out _))
|
|
{
|
|
return "Succession points toward " + SafeName(heir);
|
|
}
|
|
|
|
return memory != null && memory.Facts.Count > 0
|
|
? LifeSagaMemory.FormatFactLine(memory.Facts[0])
|
|
: "";
|
|
}
|
|
|
|
private static void BuildCast(
|
|
LifeSagaViewModel model,
|
|
LifeSagaSlot slot,
|
|
Actor actor,
|
|
LifeSagaLifeMemory memory,
|
|
LifeSagaLens primary)
|
|
{
|
|
var seen = new HashSet<long>();
|
|
long selfId = model.UnitId;
|
|
seen.Add(selfId);
|
|
|
|
void AddLive(Actor other, string label, string truth)
|
|
{
|
|
if (other == null || model.Cast.Count >= 4)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id = EventFeedUtil.SafeId(other);
|
|
if (id == 0 || !seen.Add(id))
|
|
{
|
|
return;
|
|
}
|
|
|
|
model.Cast.Add(new LifeSagaCastMember
|
|
{
|
|
UnitId = id,
|
|
Name = SafeName(other),
|
|
SpeciesId = other.asset != null ? other.asset.id : "",
|
|
Relation = label,
|
|
Truth = truth,
|
|
Alive = true
|
|
});
|
|
}
|
|
|
|
void AddObserved(LifeSagaIdentity identity, string label, string truth)
|
|
{
|
|
if (identity.Id == 0 || model.Cast.Count >= 4 || !seen.Add(identity.Id))
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool alive = EventFeedUtil.FindAliveById(identity.Id) != null;
|
|
model.Cast.Add(new LifeSagaCastMember
|
|
{
|
|
UnitId = identity.Id,
|
|
Name = FirstNonEmpty(identity.Name, "Someone"),
|
|
SpeciesId = identity.SpeciesId ?? "",
|
|
Relation = label,
|
|
Truth = truth,
|
|
Alive = alive
|
|
});
|
|
}
|
|
|
|
if (actor != null)
|
|
{
|
|
AddLive(ActorRelation.GetLover(actor), "Lover", "is");
|
|
AddLive(ActorRelation.GetBestFriend(actor), "Best friend", "is");
|
|
foreach (Actor parent in ActorRelation.EnumerateParents(actor, 2))
|
|
{
|
|
AddLive(parent, "Parent", "is");
|
|
}
|
|
|
|
foreach (Actor child in ActorRelation.EnumerateChildren(actor, 2))
|
|
{
|
|
AddLive(child, "Child", "is");
|
|
}
|
|
|
|
if (TryGetLikelySuccessor(actor, out Actor heir, out _))
|
|
{
|
|
AddLive(heir, "Likely successor", "likely");
|
|
}
|
|
}
|
|
|
|
if (LifeSagaMemory.TryGetEarnedRival(model.UnitId, out LifeSagaFact rivalFact))
|
|
{
|
|
Actor liveRival = EventFeedUtil.FindAliveById(rivalFact.OtherId);
|
|
if (liveRival != null)
|
|
{
|
|
AddLive(liveRival, "Earned rival", "observed");
|
|
}
|
|
else
|
|
{
|
|
AddObserved(rivalFact.Other, "Earned rival", "observed");
|
|
}
|
|
}
|
|
|
|
// Former bonds from memory when live cast still has room.
|
|
if (memory != null && model.Cast.Count < 4)
|
|
{
|
|
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++)
|
|
{
|
|
LifeSagaFact f = memory.Facts[i];
|
|
if (f == null || f.OtherId == 0 || seen.Contains(f.OtherId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (f.Kind == LifeSagaFactKind.BondBroken)
|
|
{
|
|
AddObserved(f.Other, "Lost lover", "observed");
|
|
}
|
|
else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null)
|
|
{
|
|
AddObserved(f.Other, "Child", "observed");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Living Others from unresolved war/plot joins.
|
|
if (memory != null && model.Cast.Count < 4)
|
|
{
|
|
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++)
|
|
{
|
|
LifeSagaFact f = memory.Facts[i];
|
|
if (f == null || f.Resolved || f.OtherId == 0 || seen.Contains(f.OtherId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Actor liveOther = EventFeedUtil.FindAliveById(f.OtherId);
|
|
if (liveOther == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (f.Kind == LifeSagaFactKind.WarJoin)
|
|
{
|
|
AddLive(liveOther, "War foe", "observed");
|
|
}
|
|
else if (f.Kind == LifeSagaFactKind.PlotJoin)
|
|
{
|
|
AddLive(liveOther, "Plot ally", "observed");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Family peers fill remaining slots for crown/combat/plot/pack/bond lenses.
|
|
if (actor != null
|
|
&& model.Cast.Count < 4
|
|
&& (primary == LifeSagaLens.PackAlpha
|
|
|| primary == LifeSagaLens.LoveGrief
|
|
|| primary == LifeSagaLens.CrownClan
|
|
|| primary == LifeSagaLens.WarriorKiller
|
|
|| primary == LifeSagaLens.Plotter))
|
|
{
|
|
string peerLabel = primary == LifeSagaLens.PackAlpha ? "Packmate" : "Kin";
|
|
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: 8))
|
|
{
|
|
if (peer == null || peer == actor)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
AddLive(peer, peerLabel, "is");
|
|
if (model.Cast.Count >= 4)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
_ = slot;
|
|
}
|
|
|
|
private static string FormatLensEyebrow(LifeSagaLens primary, LifeSagaLens secondary)
|
|
{
|
|
string a = LensLabel(primary);
|
|
string b = LensLabel(secondary);
|
|
if (string.IsNullOrEmpty(a))
|
|
{
|
|
return b;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(b) || string.Equals(a, b, StringComparison.Ordinal))
|
|
{
|
|
return a;
|
|
}
|
|
|
|
return a + " · " + b;
|
|
}
|
|
|
|
private static string LensLabel(LifeSagaLens lens)
|
|
{
|
|
switch (lens)
|
|
{
|
|
case LifeSagaLens.CrownClan: return "Crown";
|
|
case LifeSagaLens.PackAlpha: return "Pack";
|
|
case LifeSagaLens.WarriorKiller: return "Combat";
|
|
case LifeSagaLens.Plotter: return "Intrigue";
|
|
case LifeSagaLens.FounderWanderer: return "Journey";
|
|
case LifeSagaLens.LoveGrief: return "Bond";
|
|
case LifeSagaLens.LastOfKind: return "Survival";
|
|
default: return "";
|
|
}
|
|
}
|
|
|
|
private static void BuildLegacy(LifeSagaViewModel model, long unitId, LifeSagaFact stake)
|
|
{
|
|
var castIds = new HashSet<long>();
|
|
for (int i = 0; i < model.Cast.Count; i++)
|
|
{
|
|
if (model.Cast[i] != null && model.Cast[i].UnitId != 0)
|
|
{
|
|
castIds.Add(model.Cast[i].UnitId);
|
|
}
|
|
}
|
|
|
|
int parentedCount = LifeSagaMemory.CountParentedChildren(unitId);
|
|
// Many children: prefer one lineage summary over named birth crumbs.
|
|
bool preferFamilySummary = parentedCount >= 3;
|
|
bool wroteParentChild = false;
|
|
|
|
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
|
|
var seenLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
for (int i = 0; i < facts.Count; i++)
|
|
{
|
|
LifeSagaFact f = facts[i];
|
|
if (f == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Stake already owns the strongest unresolved beat - do not repeat it in Legacy.
|
|
if (stake != null
|
|
&& ((!string.IsNullOrEmpty(stake.Key)
|
|
&& string.Equals(stake.Key, f.Key, StringComparison.Ordinal))
|
|
|| (stake.Kind == f.Kind && stake.Other.Id != 0 && stake.Other.Id == f.Other.Id)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Living Cast already owns present relationships - Legacy is turning points.
|
|
if (CastOwnsLivingRelation(f, castIds))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (f.Kind == LifeSagaFactKind.ParentChild)
|
|
{
|
|
if (preferFamilySummary || wroteParentChild)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
string line = LifeSagaMemory.FormatLegacyLine(f);
|
|
if (string.IsNullOrEmpty(line)
|
|
|| string.Equals(line, model.StakeLine, StringComparison.OrdinalIgnoreCase)
|
|
|| !seenLines.Add(line))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
model.Legacy.Add(new LifeSagaLegacyBeat
|
|
{
|
|
Kind = f.Kind,
|
|
Line = line,
|
|
Truth = f.Resolved ? "observed" : "observed",
|
|
At = f.At,
|
|
DateLabel = f.DateLabel ?? ""
|
|
});
|
|
|
|
if (f.Kind == LifeSagaFactKind.ParentChild)
|
|
{
|
|
wroteParentChild = true;
|
|
}
|
|
|
|
if (model.Legacy.Count >= 4)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!wroteParentChild
|
|
&& parentedCount >= 2
|
|
&& model.Legacy.Count < 4)
|
|
{
|
|
string summary = LifeSagaMemory.FormatFamilySummary(parentedCount);
|
|
if (!string.IsNullOrEmpty(summary)
|
|
&& !string.Equals(summary, model.StakeLine, StringComparison.OrdinalIgnoreCase)
|
|
&& seenLines.Add(summary))
|
|
{
|
|
model.Legacy.Add(new LifeSagaLegacyBeat
|
|
{
|
|
Kind = LifeSagaFactKind.ParentChild,
|
|
Line = summary,
|
|
Truth = "observed",
|
|
At = 0f,
|
|
DateLabel = ""
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Living Cast already presents lover/friend/kin - do not echo those names as Legacy crumbs.
|
|
/// Dead / past relations and wars/plots/kills still belong in Legacy.
|
|
/// </summary>
|
|
private static bool CastOwnsLivingRelation(LifeSagaFact fact, HashSet<long> castIds)
|
|
{
|
|
if (fact == null || castIds == null || castIds.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
long otherId = fact.OtherId != 0 ? fact.OtherId : fact.Other.Id;
|
|
if (otherId == 0 || !castIds.Contains(otherId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
switch (fact.Kind)
|
|
{
|
|
case LifeSagaFactKind.BondFormed:
|
|
case LifeSagaFactKind.FriendFormed:
|
|
case LifeSagaFactKind.ParentChild:
|
|
// Only skip when the cast member is still alive (Cast shows present faces).
|
|
return EventFeedUtil.FindAliveById(otherId) != null;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string KingdomName(Actor actor)
|
|
{
|
|
try { return actor?.kingdom?.name ?? ""; }
|
|
catch { return ""; }
|
|
}
|
|
|
|
private static string CityName(Actor actor)
|
|
{
|
|
try { return actor?.city?.name ?? ""; }
|
|
catch { return ""; }
|
|
}
|
|
|
|
private static string SafeName(Actor actor)
|
|
{
|
|
try
|
|
{
|
|
string name = actor?.getName();
|
|
if (!string.IsNullOrEmpty(name))
|
|
{
|
|
return name;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return actor?.asset?.id ?? "";
|
|
}
|
|
|
|
private static string FirstNonEmpty(params string[] values)
|
|
{
|
|
if (values == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
for (int i = 0; i < values.Length; i++)
|
|
{
|
|
if (!string.IsNullOrEmpty(values[i]))
|
|
{
|
|
return values[i];
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
}
|
|
|
|
public enum LifeSagaLens
|
|
{
|
|
None,
|
|
CrownClan,
|
|
PackAlpha,
|
|
WarriorKiller,
|
|
Plotter,
|
|
FounderWanderer,
|
|
LoveGrief,
|
|
LastOfKind
|
|
}
|
|
|
|
public sealed class LifeSagaCastMember
|
|
{
|
|
public long UnitId;
|
|
public string Name = "";
|
|
public string SpeciesId = "";
|
|
public string Relation = "";
|
|
public string Truth = "is";
|
|
public bool Alive;
|
|
}
|
|
|
|
public sealed class LifeSagaLegacyBeat
|
|
{
|
|
public LifeSagaFactKind Kind;
|
|
public string Line = "";
|
|
public string Truth = "observed";
|
|
public float At;
|
|
public string DateLabel = "";
|
|
}
|
|
|
|
public sealed class LifeSagaViewModel
|
|
{
|
|
public long UnitId;
|
|
public string Name = "";
|
|
public string Title = "";
|
|
public string SpeciesId = "";
|
|
public string StakeLine = "";
|
|
public string StakeProvenance = "";
|
|
public LifeSagaLens PrimaryLens;
|
|
public LifeSagaLens SecondaryLens;
|
|
public string LensEyebrow = "";
|
|
public readonly List<LifeSagaCastMember> Cast = new List<LifeSagaCastMember>(4);
|
|
public readonly List<LifeSagaLegacyBeat> Legacy = new List<LifeSagaLegacyBeat>(5);
|
|
|
|
public string ToPlainText()
|
|
{
|
|
var sb = new StringBuilder(320);
|
|
if (!string.IsNullOrEmpty(Name))
|
|
{
|
|
sb.Append(Name);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(LensEyebrow))
|
|
{
|
|
if (sb.Length > 0) sb.Append('\n');
|
|
sb.Append(LensEyebrow);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(Title))
|
|
{
|
|
if (sb.Length > 0) sb.Append('\n');
|
|
sb.Append(Title);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(StakeLine))
|
|
{
|
|
if (sb.Length > 0) sb.Append('\n');
|
|
sb.Append("At stake ").Append(StakeLine);
|
|
}
|
|
|
|
if (Cast.Count > 0)
|
|
{
|
|
if (sb.Length > 0) sb.Append('\n');
|
|
sb.Append("Cast ");
|
|
for (int i = 0; i < Cast.Count; i++)
|
|
{
|
|
if (i > 0) sb.Append(" · ");
|
|
sb.Append(Cast[i].Relation).Append(' ').Append(Cast[i].Name);
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < Legacy.Count; i++)
|
|
{
|
|
if (sb.Length > 0) sb.Append('\n');
|
|
sb.Append("Legacy ").Append(Legacy[i].Line);
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
}
|