using System; using System.Collections.Generic; using System.Reflection; using System.Text; using UnityEngine; namespace IdleSpectator; /// /// Adaptive saga presentation model. Consumes live actor state + LifeSagaMemory. /// No task/status/trait/Chronicle dumps. /// 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; } public static bool HarnessProbeCivilizedTerminology(Actor actor, out string detail) { bool civilized = false; try { civilized = actor != null && actor.asset != null && actor.asset.civ; } catch { civilized = false; } if (!civilized) { detail = "civilized=false"; return false; } LifeSagaViewModel model = Build(EventFeedUtil.SafeId(actor)); string panel = model?.ToPlainText() ?? ""; string relation = EventReason.FamilyPack(actor, null, "join"); bool packAlpha = LifeSagaRoster.IsPackAlpha(actor); bool leaked = panel.IndexOf("Packmate", StringComparison.OrdinalIgnoreCase) >= 0 || panel.IndexOf("Pack Alpha", StringComparison.OrdinalIgnoreCase) >= 0 || relation.IndexOf("pack", StringComparison.OrdinalIgnoreCase) >= 0; bool pass = !packAlpha && !leaked; detail = "alpha=" + packAlpha + " relation='" + relation + "'" + " panelPack=" + (panel.IndexOf("pack", StringComparison.OrdinalIgnoreCase) >= 0) + " pass=" + pass; return pass; } /// /// Pick the strongest unresolved pressure that adds information beyond Identity. /// Current-role/founding facts belong in the title, not a duplicate tooltip stake. /// 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; } /// Compatibility plain text for harness diagnostics. public static string BuildPlainText(long unitId) { return Build(unitId).ToPlainText(); } public static string BuildPlainText(LifeSagaSlot slot) { return Build(slot?.UnitId ?? 0).ToPlainText(); } /// /// Live probe: only report an heir when a game-authored succession API is discoverable. /// 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(); 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: if (LifeSagaRoster.UsesPackLanguage(actor)) { 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) { if (LifeSagaRoster.UsesPackLanguage(actor)) { Add(LifeSagaLens.PackAlpha, 14f); } } else { Add(LifeSagaLens.CrownClan, 12f); } break; case LifeSagaFactKind.ParentChild: if (LifeSagaRoster.UsesPackLanguage(actor)) { 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 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 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 && LifeSagaRoster.UsesPackLanguage(actor) ? 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(); 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); } } model.Legacy.AddRange( LegacyChapterBuilder.Build(unitId, model.Title, stake, castIds, displayLimit: 4)); } 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 string ChapterId = ""; public string SourceEventId = ""; public string SourceDevelopmentId = ""; public string SourceThreadId = ""; public LifeSagaFactKind Kind; public string Line = ""; public string Truth = "observed"; public float At; public float Importance; 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 Cast = new List(4); public readonly List Legacy = new List(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(); } }