worldbox-observer-mod/IdleSpectator/Story/LifeSagaPresentation.cs
DazedAnon 58a67a198d feat(saga): replace tabs with character beat captions
- Compose Identity, Beat, and Context without mutating camera labels
- Move Cast and Legacy details into a hover-only panel
- Centralize relation, title, stake, and legacy prose
- Make rail clicks toggle Prefer without pinning a Saga subject
- Add harness coverage for caption composition and hover behavior
2026-07-22 17:09:13 -05:00

916 lines
28 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 = StrongestDisplayStake(unitId, model.Title);
model.StakeLine = stake != null
? LifeSagaMemory.FormatFactLine(stake)
: (string.IsNullOrEmpty(model.Title) ? 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>
/// Pick the strongest unresolved pressure that adds information beyond Identity.
/// Current-role/founding facts belong in the title, not a duplicate tooltip stake.
/// </summary>
private static LifeSagaFact StrongestDisplayStake(long unitId, string title)
{
LifeSagaFact best = null;
var facts = LifeSagaMemory.FactsFor(unitId);
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact fact = facts[i];
if (fact == null || fact.Resolved || !LifeSagaMemory.IsUnresolvedStakeCandidate(fact))
{
continue;
}
bool repeatsIdentity = SagaProse.RoleChangeEchoesTitle(fact, title)
|| (fact.Kind == LifeSagaFactKind.Founding
&& !string.IsNullOrEmpty(title)
&& title.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0);
if (repeatsIdentity)
{
continue;
}
if (best == null
|| fact.Strength > best.Strength
|| (Mathf.Approximately(fact.Strength, best.Strength) && fact.At > best.At))
{
best = fact;
}
}
return best;
}
/// <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)
{
return SagaProse.Title(slot, actor, memory);
}
private static string BuildFallbackStake(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
{
if (LifeSagaMemory.TryGetEarnedRival(slot?.UnitId ?? 0, out LifeSagaFact rival))
{
return SagaProse.StakeLine(rival);
}
if (slot != null)
{
string roleStake = SagaProse.RoleFallbackStake(slot.AdmissionReason, slot, actor);
if (!string.IsNullOrEmpty(roleStake))
{
return roleStake;
}
}
if (actor != null && TryGetLikelySuccessor(actor, out Actor heir, out _))
{
return SagaProse.Sentence("Succession points toward " + SafeName(heir));
}
return memory != null && memory.Facts.Count > 0
? SagaProse.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 >= LifeSagaPanel.MaxCast)
{
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 >= LifeSagaPanel.MaxCast || !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), SagaProse.CastLabelLive("lover"), "is");
AddLive(ActorRelation.GetBestFriend(actor), SagaProse.CastLabelLive("best friend"), "is");
foreach (Actor parent in ActorRelation.EnumerateParents(actor, 2))
{
AddLive(parent, SagaProse.CastLabelLive("parent"), "is");
}
foreach (Actor child in ActorRelation.EnumerateChildren(actor, 2))
{
AddLive(child, SagaProse.CastLabelLive("child"), "is");
}
if (TryGetLikelySuccessor(actor, out Actor heir, out _))
{
AddLive(heir, SagaProse.CastLabelLive("heir"), "likely");
}
}
if (LifeSagaMemory.TryGetEarnedRival(model.UnitId, out LifeSagaFact rivalFact))
{
string foeLabel = SagaProse.CastLabel(rivalFact, EventFeedUtil.FindAliveById(rivalFact.OtherId));
if (string.IsNullOrEmpty(foeLabel))
{
foeLabel = SagaProse.TitleLabel("Old Foe");
}
Actor liveRival = EventFeedUtil.FindAliveById(rivalFact.OtherId);
if (liveRival != null)
{
AddLive(liveRival, foeLabel, "observed");
}
else
{
AddObserved(rivalFact.Other, foeLabel, "observed");
}
}
// Former bonds from memory when live cast still has room.
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
{
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; 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, SagaProse.CastLabelLive("once loved"), "observed");
}
else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null)
{
AddObserved(f.Other, SagaProse.CastLabelLive("child"), "observed");
}
}
}
// Living Others from unresolved war/plot joins.
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
{
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; 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, SagaProse.TitleLabel("War Enemy"), "observed");
}
else if (f.Kind == LifeSagaFactKind.PlotJoin)
{
AddLive(liveOther, SagaProse.TitleLabel("Plot Partner"), "observed");
}
}
}
// Family peers fill remaining slots for crown/combat/plot/pack/bond lenses.
if (actor != null
&& model.Cast.Count < LifeSagaPanel.MaxCast
&& (primary == LifeSagaLens.PackAlpha
|| primary == LifeSagaLens.LoveGrief
|| primary == LifeSagaLens.CrownClan
|| primary == LifeSagaLens.WarriorKiller
|| primary == LifeSagaLens.Plotter))
{
string peerLabel = primary == LifeSagaLens.PackAlpha
? SagaProse.CastLabelLive("packmate")
: SagaProse.CastLabelLive("kin");
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCast + 2))
{
if (peer == null || peer == actor)
{
continue;
}
AddLive(peer, peerLabel, "is");
if (model.Cast.Count >= LifeSagaPanel.MaxCast)
{
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;
// Reserve the final Legacy slot for that summary; otherwise a death/bond/war
// burst can fill all four slots and make the lineage disappear nondeterministically.
int factLimit = parentedCount >= 2 ? 3 : 4;
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;
}
// Current role is already stated in Identity (for example
// "Adisolf · King of Nuanand"). Do not repeat "Became King" below it.
if (f.Kind == LifeSagaFactKind.RoleChange
&& SagaProse.RoleChangeEchoesTitle(f, model.Title))
{
continue;
}
if (f.Kind == LifeSagaFactKind.Founding
&& !string.IsNullOrEmpty(model.Title)
&& model.Title.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0)
{
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 >= factLimit)
{
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();
}
}