worldbox-observer-mod/IdleSpectator/Story/LifeSagaPresentation.cs
DazedAnon 739cc6492c feat(spectator): make narrative threads own camera direction
- 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
2026-07-23 12:17:18 -05:00

1081 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}