worldbox-observer-mod/IdleSpectator/Story/LifeSagaPresentation.cs
DazedAnon 4b6c829bd0 feat(saga): ship adaptive dossier tabs with durable memory
- Add Dossier/Saga tabs, Design A panel, and hover preview with read-pause
- Persist observed saga facts across roster churn; pin subject without stale fallback
- Restore Saga after Open Lore; hide redundant Kind · Climax spine under matching tips
- Densify dossier header/chips and keep the cast rail as stable top chrome
- Extend harness coverage for hover restore, empty pick, Evidence, and Lore return
2026-07-22 03:27:46 -05:00

871 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);
BuildEvidence(model, primary, secondary, slot, actor, memory);
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.FounderWanderer;
secondary = LifeSagaLens.None;
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");
}
}
}
// Pack / kin peers fill remaining slots for pack-shaped and family-shaped lenses.
if (actor != null
&& model.Cast.Count < 4
&& (primary == LifeSagaLens.PackAlpha || primary == LifeSagaLens.LoveGrief))
{
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 void BuildEvidence(
LifeSagaViewModel model,
LifeSagaLens primary,
LifeSagaLens secondary,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory)
{
model.EvidenceTitle = EvidenceTitle(primary);
model.EvidenceLine = EvidenceLine(primary, slot, actor, memory);
if (secondary != LifeSagaLens.None)
{
model.SecondaryEvidenceTitle = EvidenceTitle(secondary);
model.SecondaryEvidenceLine = EvidenceLine(secondary, slot, actor, memory);
}
}
private static string EvidenceTitle(LifeSagaLens lens)
{
switch (lens)
{
case LifeSagaLens.CrownClan: return "Realm";
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 "Evidence";
}
}
private static string EvidenceLine(
LifeSagaLens lens,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory)
{
switch (lens)
{
case LifeSagaLens.CrownClan:
{
string realm = FirstNonEmpty(slot?.KingdomKey, KingdomName(actor));
int wars = CountFacts(memory, LifeSagaFactKind.WarJoin);
if (!string.IsNullOrEmpty(realm) && wars > 0)
{
return realm + " · " + wars + " war" + (wars == 1 ? "" : "s") + " observed";
}
return FirstNonEmpty(realm, "Holding power");
}
case LifeSagaLens.PackAlpha:
{
int children = CountFacts(memory, LifeSagaFactKind.ParentChild);
return children > 0
? "Pack line with " + children + " recorded offspring"
: "Leads the family pack";
}
case LifeSagaLens.WarriorKiller:
{
int kills = slot?.Kills ?? 0;
int encounters = CountFacts(memory, LifeSagaFactKind.CombatEncounter)
+ CountFacts(memory, LifeSagaFactKind.Kill);
if (kills > 0)
{
return kills + " kills · " + encounters + " remembered clashes";
}
return encounters > 0 ? encounters + " remembered clashes" : "A fighter's reputation";
}
case LifeSagaLens.Plotter:
{
int plots = CountFacts(memory, LifeSagaFactKind.PlotJoin);
return plots > 0 ? plots + " plot thread" + (plots == 1 ? "" : "s") : "Scheming still";
}
case LifeSagaLens.FounderWanderer:
{
int founded = CountFacts(memory, LifeSagaFactKind.Founding);
return founded > 0 ? "Founded " + founded + " lasting place" + (founded == 1 ? "" : "s")
: FirstNonEmpty(CityName(actor), "Building a place in the world");
}
case LifeSagaLens.LoveGrief:
{
int bonds = CountFacts(memory, LifeSagaFactKind.BondFormed);
int losses = CountFacts(memory, LifeSagaFactKind.BondBroken);
if (losses > 0)
{
return "Carrying " + losses + " observed loss" + (losses == 1 ? "" : "es");
}
return bonds > 0 ? "Bound " + bonds + " time" + (bonds == 1 ? "" : "s") : "A life shaped by attachment";
}
case LifeSagaLens.LastOfKind:
return "The last living thread of their kind";
default:
return "";
}
}
private static void BuildLegacy(LifeSagaViewModel model, long unitId, LifeSagaFact stake)
{
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 5);
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;
}
string line = LifeSagaMemory.FormatFactLine(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 (model.Legacy.Count >= 4)
{
break;
}
}
}
private static int CountFacts(LifeSagaLifeMemory memory, LifeSagaFactKind kind)
{
if (memory == null)
{
return 0;
}
int n = 0;
for (int i = 0; i < memory.Facts.Count; i++)
{
if (memory.Facts[i] != null && memory.Facts[i].Kind == kind)
{
n++;
}
}
return n;
}
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 EvidenceTitle = "";
public string EvidenceLine = "";
public string SecondaryEvidenceTitle = "";
public string SecondaryEvidenceLine = "";
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(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);
}
}
if (!string.IsNullOrEmpty(EvidenceLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(EvidenceTitle).Append(" ").Append(EvidenceLine);
}
if (!string.IsNullOrEmpty(SecondaryEvidenceLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(SecondaryEvidenceTitle).Append(" ").Append(SecondaryEvidenceLine);
}
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();
}
}