- Route confirmed life events into evidence-based threads, consequences, and Legacy chapters - Schedule episode shots with critical interrupts, replay guards, and a combat budget - Admit emerging main characters through story momentum instead of spectacle - Remove causal heat, hard-arc memory writes, and synthetic epilogue continuity - Expand narrative harness coverage, soak auditing, and product documentation
1081 lines
35 KiB
C#
1081 lines
35 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.MaxCastCandidates)
|
||
{
|
||
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.MaxCastCandidates || !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.MaxCastCandidates)
|
||
{
|
||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; 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.MaxCastCandidates)
|
||
{
|
||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; 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.MaxCastCandidates
|
||
&& (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.MaxCastCandidates + 2))
|
||
{
|
||
if (peer == null || peer == actor)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
AddLive(peer, peerLabel, "is");
|
||
if (model.Cast.Count >= LifeSagaPanel.MaxCastCandidates)
|
||
{
|
||
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);
|
||
List<NarrativeChapter> narrativeChapters = NarrativeStoryStore.ChaptersFor(unitId, 4);
|
||
bool narrativeOwnsFamily = false;
|
||
bool narrativeOwnsConflict = false;
|
||
for (int i = 0; i < narrativeChapters.Count && model.Legacy.Count < 4; i++)
|
||
{
|
||
NarrativeChapter chapter = narrativeChapters[i];
|
||
if (chapter == null || string.IsNullOrEmpty(chapter.Line))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string line = !string.IsNullOrEmpty(chapter.DateLabel)
|
||
? chapter.DateLabel + " · " + chapter.Line
|
||
: chapter.Line;
|
||
if (!seenLines.Add(line))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
NarrativeThread chapterThread = NarrativeStoryStore.Thread(chapter.ThreadId);
|
||
if (chapterThread != null)
|
||
{
|
||
narrativeOwnsFamily |= chapterThread.Kind == NarrativeThreadKind.Partnership
|
||
|| chapterThread.Kind == NarrativeThreadKind.Lineage
|
||
|| chapterThread.Kind == NarrativeThreadKind.SeparationGrief;
|
||
narrativeOwnsConflict |= chapterThread.Kind == NarrativeThreadKind.Rivalry
|
||
|| chapterThread.Kind == NarrativeThreadKind.PersonalCombat
|
||
|| chapterThread.Kind == NarrativeThreadKind.WarInvolvement;
|
||
}
|
||
|
||
model.Legacy.Add(new LifeSagaLegacyBeat
|
||
{
|
||
Kind = NarrativeLegacyKind(chapterThread),
|
||
Line = line,
|
||
Truth = "observed",
|
||
At = chapter.At,
|
||
DateLabel = chapter.DateLabel ?? ""
|
||
});
|
||
}
|
||
|
||
bool wroteCombatSummary = false;
|
||
if (TryBuildCombatSummary(unitId, out LifeSagaLegacyBeat combatSummary))
|
||
{
|
||
// PersonalCombat chapters are still one chapter per opponent. Replace those
|
||
// repeated kill lines with one observed run; earned Rivalry chapters use a
|
||
// different kind and remain intact.
|
||
for (int i = model.Legacy.Count - 1; i >= 0; i--)
|
||
{
|
||
if (model.Legacy[i] != null && model.Legacy[i].Kind == LifeSagaFactKind.Kill)
|
||
{
|
||
model.Legacy.RemoveAt(i);
|
||
}
|
||
}
|
||
|
||
if (model.Legacy.Count < factLimit && seenLines.Add(combatSummary.Line))
|
||
{
|
||
model.Legacy.Add(combatSummary);
|
||
wroteCombatSummary = true;
|
||
}
|
||
}
|
||
|
||
for (int i = 0; i < facts.Count; i++)
|
||
{
|
||
if (model.Legacy.Count >= factLimit)
|
||
{
|
||
break;
|
||
}
|
||
|
||
LifeSagaFact f = facts[i];
|
||
if (f == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (narrativeOwnsFamily
|
||
&& (f.Kind == LifeSagaFactKind.BondFormed
|
||
|| f.Kind == LifeSagaFactKind.BondBroken
|
||
|| f.Kind == LifeSagaFactKind.ParentChild
|
||
|| f.Kind == LifeSagaFactKind.HardArcLove
|
||
|| f.Kind == LifeSagaFactKind.HardArcGrief))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (narrativeOwnsConflict
|
||
&& (f.Kind == LifeSagaFactKind.Kill
|
||
|| f.Kind == LifeSagaFactKind.HardArcCombat
|
||
|| f.Kind == LifeSagaFactKind.HardArcWar))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (wroteCombatSummary && f.Kind == LifeSagaFactKind.Kill)
|
||
{
|
||
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
|
||
&& !narrativeOwnsFamily
|
||
&& 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 = ""
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
private static bool TryBuildCombatSummary(long unitId, out LifeSagaLegacyBeat summary)
|
||
{
|
||
summary = null;
|
||
IReadOnlyList<LifeSagaFact> all = LifeSagaMemory.FactsFor(unitId);
|
||
if (all == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
int kills = 0;
|
||
LifeSagaFact earliest = null;
|
||
LifeSagaFact latest = null;
|
||
for (int i = 0; i < all.Count; i++)
|
||
{
|
||
LifeSagaFact fact = all[i];
|
||
if (fact == null || fact.Kind != LifeSagaFactKind.Kill)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
kills++;
|
||
if (earliest == null || fact.At < earliest.At) earliest = fact;
|
||
if (latest == null || fact.At > latest.At) latest = fact;
|
||
}
|
||
|
||
if (kills < 2 || latest == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
string date = latest.DateLabel ?? "";
|
||
if (earliest != null
|
||
&& !string.IsNullOrEmpty(earliest.DateLabel)
|
||
&& !string.IsNullOrEmpty(latest.DateLabel)
|
||
&& !string.Equals(earliest.DateLabel, latest.DateLabel, StringComparison.Ordinal))
|
||
{
|
||
date = earliest.DateLabel + "–" + latest.DateLabel;
|
||
}
|
||
|
||
string body = "Slew " + kills + " opponents across repeated clashes";
|
||
string line = string.IsNullOrEmpty(date) ? body : date + " · " + body;
|
||
summary = new LifeSagaLegacyBeat
|
||
{
|
||
Kind = LifeSagaFactKind.Kill,
|
||
Line = line,
|
||
Truth = "observed",
|
||
At = latest.At,
|
||
DateLabel = date
|
||
};
|
||
return true;
|
||
}
|
||
|
||
private static LifeSagaFactKind NarrativeLegacyKind(NarrativeThread thread)
|
||
{
|
||
if (thread == null) return LifeSagaFactKind.Unknown;
|
||
switch (thread.Kind)
|
||
{
|
||
case NarrativeThreadKind.Partnership: return LifeSagaFactKind.BondFormed;
|
||
case NarrativeThreadKind.Lineage: return LifeSagaFactKind.ParentChild;
|
||
case NarrativeThreadKind.SeparationGrief: return LifeSagaFactKind.BondBroken;
|
||
case NarrativeThreadKind.RiseToRule:
|
||
case NarrativeThreadKind.Reign:
|
||
case NarrativeThreadKind.Succession: return LifeSagaFactKind.RoleChange;
|
||
case NarrativeThreadKind.Founding: return LifeSagaFactKind.Founding;
|
||
case NarrativeThreadKind.HomeLoss: return LifeSagaFactKind.HomeLost;
|
||
case NarrativeThreadKind.Rivalry: return LifeSagaFactKind.RivalEarned;
|
||
case NarrativeThreadKind.PersonalCombat: return LifeSagaFactKind.Kill;
|
||
case NarrativeThreadKind.WarInvolvement: return LifeSagaFactKind.WarJoin;
|
||
case NarrativeThreadKind.PlotBetrayal: return LifeSagaFactKind.PlotJoin;
|
||
case NarrativeThreadKind.Transformation:
|
||
case NarrativeThreadKind.Recovery: return LifeSagaFactKind.Unknown;
|
||
default: return LifeSagaFactKind.Unknown;
|
||
}
|
||
}
|
||
|
||
/// <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();
|
||
}
|
||
}
|