using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// World-session structured saga facts keyed by unit id. /// Survives roster eviction; cleared only on world replacement / harness clear / teardown. /// Independent of ChronicleEnabled and camera feed gates. /// public static class LifeSagaMemory { public const int MaxFactsPerLife = 48; public const int MaxLives = 120; /// Fallback when scoring-model omits sagaRivalEncounterThreshold. public const int RivalEncounterThresholdDefault = 3; private const float EncounterDedupeSeconds = 28f; private static readonly Dictionary Lives = new Dictionary(64); private static readonly Dictionary PairEncounters = new Dictionary(64); private static readonly Dictionary PairEncounterCooldown = new Dictionary(64); private static object _boundWorld; private static int _worldEpoch; public static int WorldEpoch => _worldEpoch; public static int LifeCount => Lives.Count; /// /// One mutation boundary for every durable fact change. Presentation and relation /// caches must never outlive the memory state that produced their prose. /// private static void MarkMutated() { SagaProse.BumpMemoryRevision(); NarrativePersistence.MarkDirty(); } public static int RivalEncounterThreshold { get { int t = InterestScoringConfig.Story.sagaRivalEncounterThreshold; return t > 0 ? t : RivalEncounterThresholdDefault; } } public static void EnsureWorldBound() { LifeSagaSession.EnsureBound(); } /// Called by after a session reset. public static void NoteBoundWorld(object world) { _boundWorld = world; } public static void ClearSession() { Lives.Clear(); PairEncounters.Clear(); PairEncounterCooldown.Clear(); _boundWorld = null; _worldEpoch++; MarkMutated(); } public static void Clear() => ClearSession(); public static LifeSagaLifeMemory Get(long unitId) { if (unitId == 0) { return null; } EnsureWorldBound(); return Lives.TryGetValue(unitId, out LifeSagaLifeMemory life) ? life : null; } public static LifeSagaLifeMemory GetOrCreate(long unitId) { if (unitId == 0) { return null; } EnsureWorldBound(); if (Lives.TryGetValue(unitId, out LifeSagaLifeMemory existing)) { return existing; } PruneIfNeeded(); var life = new LifeSagaLifeMemory { UnitId = unitId }; Lives[unitId] = life; return life; } public static bool HasFacts(long unitId) { LifeSagaLifeMemory life = Get(unitId); return life != null && life.Facts.Count > 0; } public static IReadOnlyList FactsFor(long unitId) { LifeSagaLifeMemory life = Get(unitId); return life != null ? life.Facts : Array.Empty(); } internal static IEnumerable AllFacts() { foreach (LifeSagaLifeMemory life in Lives.Values) { if (life == null) continue; for (int i = 0; i < life.Facts.Count; i++) { if (life.Facts[i] != null) yield return life.Facts[i]; } } } internal static void ImportFact(LifeSagaFact fact) { if (fact == null || fact.SubjectId == 0 || string.IsNullOrEmpty(fact.Key)) { return; } LifeSagaLifeMemory life = GetOrCreate(fact.SubjectId); if (life == null || FindByKey(life, fact.Key) != null) { return; } if (fact.Subject.Id != 0) life.Identity = fact.Subject; life.Facts.Add(fact); life.TouchedAt = Mathf.Max(life.TouchedAt, fact.At); while (life.Facts.Count > MaxFactsPerLife) { life.Facts.RemoveAt(life.Facts.Count - 1); } } public static LifeSagaFact Record( LifeSagaFactKind kind, Actor subject, Actor other = null, string correlationKey = null, float strength = 50f, string provenance = "", string kingdomKey = "", string cityKey = "", string clanKey = "", string familyKey = "", string warKey = "", string plotKey = "", bool resolved = false, string note = "") { if (subject == null) { return null; } long subjectId = EventFeedUtil.SafeId(subject); if (subjectId == 0) { return null; } LifeSagaIdentity subjectSnap = Snapshot(subject); LifeSagaIdentity otherSnap = Snapshot(other); return RecordIds( kind, subjectId, subjectSnap, otherSnap.Id, otherSnap, correlationKey, strength, provenance, kingdomKey, cityKey, clanKey, familyKey, warKey, plotKey, resolved, note); } public static LifeSagaFact RecordIds( LifeSagaFactKind kind, long subjectId, LifeSagaIdentity subject, long otherId, LifeSagaIdentity other, string correlationKey = null, float strength = 50f, string provenance = "", string kingdomKey = "", string cityKey = "", string clanKey = "", string familyKey = "", string warKey = "", string plotKey = "", bool resolved = false, string note = "") { if (subjectId == 0) { return null; } EnsureWorldBound(); LifeSagaLifeMemory life = GetOrCreate(subjectId); if (life == null) { return null; } if (subject.Id != 0) { life.Identity = subject; } string key = string.IsNullOrEmpty(correlationKey) ? DefaultKey(kind, subjectId, otherId, warKey, plotKey) : correlationKey; LifeSagaFact existing = FindByKey(life, key); float now = Time.unscaledTime; StampWorldDate(out double worldTime, out string dateLabel); if (existing != null) { existing.Strength = Mathf.Max(existing.Strength, strength); existing.Resolved = resolved; existing.At = now; existing.WorldTime = worldTime; existing.DateLabel = dateLabel; if (!string.IsNullOrEmpty(note)) { existing.Note = note; } if (other.Id != 0 && existing.Other.Id == 0) { existing.Other = other; existing.OtherId = other.Id; } // Keep join/end kinds aligned when the same correlation key is reused. if (kind == LifeSagaFactKind.WarEnd || kind == LifeSagaFactKind.PlotEnd) { existing.Kind = kind; existing.Resolved = true; } life.TouchedAt = now; MarkMutated(); return existing; } var fact = new LifeSagaFact { Key = key, Kind = kind, SubjectId = subjectId, OtherId = otherId, Subject = subject.Id != 0 ? subject : SnapshotId(subjectId), Other = other.Id != 0 ? other : (otherId != 0 ? SnapshotId(otherId) : default), At = now, WorldTime = worldTime, DateLabel = dateLabel, Strength = strength, Provenance = provenance ?? "", KingdomKey = kingdomKey ?? "", CityKey = cityKey ?? "", ClanKey = clanKey ?? "", FamilyKey = familyKey ?? "", WarKey = warKey ?? "", PlotKey = plotKey ?? "", Resolved = resolved, Note = note ?? "" }; life.Facts.Insert(0, fact); life.TouchedAt = now; MarkMutated(); while (life.Facts.Count > MaxFactsPerLife) { life.Facts.RemoveAt(life.Facts.Count - 1); } // Mirror onto the other life for shared events (bonds, kills, kin). if (otherId != 0 && otherId != subjectId && (kind == LifeSagaFactKind.BondFormed || kind == LifeSagaFactKind.BondBroken || kind == LifeSagaFactKind.FriendFormed || kind == LifeSagaFactKind.ParentChild || kind == LifeSagaFactKind.Kill || kind == LifeSagaFactKind.Death || kind == LifeSagaFactKind.CombatEncounter || kind == LifeSagaFactKind.RivalEarned || kind == LifeSagaFactKind.WarJoin || kind == LifeSagaFactKind.PlotJoin)) { MirrorToOther(fact, otherId); } TryMarkMcCrossover(fact); return fact; } public static NarrativeEventReceipt RecordBondFormed( Actor a, Actor b, string provenance = "set_lover") { return NarrativeEventRecorder.BondFormed(a, b, provenance); } internal static LifeSagaFact RecordBondFormedFact( Actor a, Actor b, string provenance = "set_lover") { if (a == null || b == null) { return null; } return Record( LifeSagaFactKind.BondFormed, a, b, correlationKey: "bond:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)) + ":formed:" + Time.unscaledTime.ToString("0.###"), strength: 70f, provenance: provenance); } public static NarrativeEventReceipt RecordBondBroken( Actor survivor, Actor former, string provenance = "clear_lover") { return NarrativeEventRecorder.BondBroken(survivor, former, provenance); } internal static LifeSagaFact RecordBondBrokenFact( Actor survivor, Actor former, string provenance = "clear_lover") { if (survivor == null) { return null; } long survivorId = EventFeedUtil.SafeId(survivor); LifeSagaIdentity formerSnap = Snapshot(former); if (formerSnap.Id == 0) { // Fall back to last known lover fact on this life. LifeSagaLifeMemory life = Get(survivorId); if (life != null) { for (int i = 0; i < life.Facts.Count; i++) { LifeSagaFact f = life.Facts[i]; if (f != null && f.Kind == LifeSagaFactKind.BondFormed && f.OtherId != 0) { formerSnap = f.Other; break; } } } } if (formerSnap.Id == 0) { return null; } return RecordIds( LifeSagaFactKind.BondBroken, survivorId, Snapshot(survivor), formerSnap.Id, formerSnap, correlationKey: "bond:" + PairKey(survivorId, formerSnap.Id) + ":broken:" + Time.unscaledTime.ToString("0.###"), strength: 75f, provenance: provenance, resolved: true); } public static NarrativeEventReceipt RecordParentChild( Actor parent, Actor child, string provenance = "add_child") { return NarrativeEventRecorder.ChildBorn(parent, child, provenance); } internal static LifeSagaFact RecordParentChildFact( Actor parent, Actor child, string provenance = "add_child") { if (parent == null || child == null) { return null; } long parentId = EventFeedUtil.SafeId(parent); long childId = EventFeedUtil.SafeId(child); return Record( LifeSagaFactKind.ParentChild, parent, child, correlationKey: "parent:" + parentId + ":" + childId, strength: 65f, provenance: provenance); } public static NarrativeEventReceipt RecordFriendFormed( Actor a, Actor b, string provenance = "set_best_friend") { return NarrativeEventRecorder.FriendFormed(a, b, provenance); } internal static LifeSagaFact RecordFriendFormedFact( Actor a, Actor b, string provenance = "set_best_friend") { if (a == null || b == null) { return null; } return Record( LifeSagaFactKind.FriendFormed, a, b, correlationKey: "friend:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)), strength: 55f, provenance: provenance); } public static NarrativeEventReceipt RecordKill( Actor killer, Actor victim, string provenance = "newKillAction") { if (killer == null || victim == null) { return new NarrativeEventReceipt { RejectionReason = "missing_actor" }; } long killerId = EventFeedUtil.SafeId(killer); long victimId = EventFeedUtil.SafeId(victim); NarrativeEventReceipt receipt = NarrativeEventRecorder.Kill(killer, victim, provenance); if (receipt.IsNew) { // Kill/death must not increment living rematch counters (single kill ≠ rival). TryMarkKinKillRival(killer, victim); if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId)) { // MC slew MC: crossover stake only - not automatic RivalEarned. NoteMcCrossoverHeat(killerId, victimId); } } return receipt; } internal static LifeSagaFact RecordKillFact( Actor killer, Actor victim, string provenance = "newKillAction") { if (killer == null || victim == null) { return null; } long killerId = EventFeedUtil.SafeId(killer); long victimId = EventFeedUtil.SafeId(victim); return Record( LifeSagaFactKind.Kill, killer, victim, correlationKey: "kill:" + killerId + ":" + victimId + ":" + Time.unscaledTime.ToString("0.###"), strength: 80f, provenance: provenance); } public static NarrativeEventReceipt RecordDeath( Actor victim, Actor killer, string attackType, string provenance = "die") { if (victim == null) { return new NarrativeEventReceipt { RejectionReason = "missing_actor" }; } long victimId = EventFeedUtil.SafeId(victim); NarrativeEventReceipt receipt = NarrativeEventRecorder.Death(victim, killer, attackType, provenance); if (receipt.IsNew && killer != null) { long killerId = EventFeedUtil.SafeId(killer); if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId)) { NoteMcCrossoverHeat(killerId, victimId); } } return receipt; } internal static LifeSagaFact RecordDeathFact( Actor victim, Actor killer, string attackType, string provenance = "die") { if (victim == null) { return null; } long victimId = EventFeedUtil.SafeId(victim); return Record( LifeSagaFactKind.Death, victim, killer, correlationKey: "death:" + victimId, strength: 90f, provenance: provenance, resolved: true, note: attackType ?? ""); } /// /// Living rematch counter. Call only while both actors are alive (sticky combat / climax). /// Kill and death paths must not call this. Deduped per pair so one scrap cannot spam to threshold. /// public static void NoteCombatEncounter(Actor a, Actor b, string provenance = "combat") { NoteCombatEncounterCore(a, b, provenance, bypassCooldown: false); } /// Harness: count distinct living rematches without waiting out the dedupe window. public static void HarnessNoteCombatEncounter(Actor a, Actor b, string provenance = "harness") { NoteCombatEncounterCore(a, b, provenance, bypassCooldown: true); } private static void NoteCombatEncounterCore( Actor a, Actor b, string provenance, bool bypassCooldown) { if (a == null || b == null) { return; } try { if (!a.isAlive() || !b.isAlive()) { return; } } catch { return; } long idA = EventFeedUtil.SafeId(a); long idB = EventFeedUtil.SafeId(b); if (idA == 0 || idB == 0 || idA == idB) { return; } string pair = PairKey(idA, idB); float now = Time.unscaledTime; if (!bypassCooldown && PairEncounterCooldown.TryGetValue(pair, out float until) && now < until) { return; } PairEncounterCooldown[pair] = now + EncounterDedupeSeconds; if (!PairEncounters.TryGetValue(pair, out int count)) { count = 0; } count++; PairEncounters[pair] = count; Record( LifeSagaFactKind.CombatEncounter, a, b, correlationKey: "encounter:" + pair, strength: 40f + Mathf.Min(20f, count * 5f), provenance: provenance, note: "x" + count); int need = RivalEncounterThreshold; bool bothMc = LifeSagaRoster.IsMc(idA) && LifeSagaRoster.IsMc(idB); // MC↔MC living rematches earn rivalry one encounter sooner (still never from a single kill). if (bothMc) { need = Mathf.Max(2, need - 1); } if (count >= need) { string evidence = bothMc ? "mc_rematch:" + count : "repeated_encounters:" + count; EarnRival(a, b, evidence, provenance); } } public static void EarnRival( Actor subject, Actor rival, string evidence, string provenance, float strength = 85f) { if (subject == null || rival == null) { return; } long subjectId = EventFeedUtil.SafeId(subject); long rivalId = EventFeedUtil.SafeId(rival); LifeSagaFact fact = Record( LifeSagaFactKind.RivalEarned, subject, rival, correlationKey: "rival:" + PairKey(subjectId, rivalId), strength: Mathf.Clamp(strength, 85f, 100f), provenance: provenance, note: evidence ?? ""); if (fact != null && LifeSagaRoster.IsMc(subjectId) && LifeSagaRoster.IsMc(rivalId)) { fact.McCrossover = true; NoteMcCrossoverHeat(subjectId, rivalId); } } /// /// Opposing sides of the same war: durable rivalry when both units are known. /// public static void NoteWarOpposition( Actor a, Actor b, string warKey, string provenance = "war_opposition") { if (a == null || b == null || string.IsNullOrEmpty(warKey)) { return; } try { if (!a.isAlive() || !b.isAlive()) { return; } } catch { return; } long idA = EventFeedUtil.SafeId(a); long idB = EventFeedUtil.SafeId(b); if (idA == 0 || idB == 0 || idA == idB) { return; } EarnRival(a, b, "opposed_in_war:" + warKey, provenance); } /// /// Opposing sides of the same plot: durable rivalry when both units are known. /// public static void NotePlotOpposition( Actor a, Actor b, string plotKey, string provenance = "plot_opposition") { if (a == null || b == null || string.IsNullOrEmpty(plotKey)) { return; } try { if (!a.isAlive() || !b.isAlive()) { return; } } catch { return; } long idA = EventFeedUtil.SafeId(a); long idB = EventFeedUtil.SafeId(b); if (idA == 0 || idB == 0 || idA == idB) { return; } EarnRival(a, b, "opposed_in_plot:" + plotKey, provenance); } public static NarrativeEventReceipt RecordWar( Actor subject, string warKey, string kingdomKey, bool ended, string provenance = "war", Actor other = null, string note = "") { if (subject == null || string.IsNullOrEmpty(warKey)) { return new NarrativeEventReceipt { RejectionReason = "missing_identity" }; } return NarrativeEventRecorder.War( subject, other, warKey, kingdomKey, ended, provenance, note); } internal static LifeSagaFact RecordWarFact( Actor subject, string warKey, string kingdomKey, bool ended, string provenance = "war", Actor other = null, string note = "") { if (subject == null || string.IsNullOrEmpty(warKey)) { return null; } return Record( ended ? LifeSagaFactKind.WarEnd : LifeSagaFactKind.WarJoin, subject, other, correlationKey: "war:" + warKey + ":" + EventFeedUtil.SafeId(subject), strength: ended ? 70f : 78f, provenance: provenance, kingdomKey: kingdomKey, warKey: warKey, resolved: ended, note: note ?? ""); } /// /// Snapshot both kingdom principals for a war tip/object. On end, resolve all open joins for the war key. /// public static void RecordWarPrincipals( Actor attackerPrincipal, Actor defenderPrincipal, string warKey, string attackerKingdom, string defenderKingdom, bool ended, string provenance = "war") { if (string.IsNullOrEmpty(warKey)) { return; } string pairNote = FormatWarPairNote(attackerKingdom, defenderKingdom); if (attackerPrincipal != null) { try { if (attackerPrincipal.isAlive()) { RecordWar( attackerPrincipal, warKey, attackerKingdom ?? "", ended, provenance, other: defenderPrincipal, note: pairNote); } } catch { // skip dead/invalid } } if (defenderPrincipal != null) { try { if (defenderPrincipal.isAlive()) { RecordWar( defenderPrincipal, warKey, defenderKingdom ?? "", ended, provenance, other: attackerPrincipal, note: pairNote); } } catch { // skip dead/invalid } } if (ended) { ResolveWarJoins(warKey); } } public static NarrativeEventReceipt RecordPlot( Actor subject, string plotKey, string plotAsset, bool finished, string provenance = "plot", Actor other = null) { if (subject == null || string.IsNullOrEmpty(plotKey)) { return new NarrativeEventReceipt { RejectionReason = "missing_identity" }; } return NarrativeEventRecorder.Plot( subject, other, plotKey, plotAsset, finished, provenance); } internal static LifeSagaFact RecordPlotFact( Actor subject, string plotKey, string plotAsset, bool finished, string provenance = "plot", Actor other = null) { if (subject == null || string.IsNullOrEmpty(plotKey)) { return null; } string display = ResolvePlotDisplay(plotAsset); return Record( finished ? LifeSagaFactKind.PlotEnd : LifeSagaFactKind.PlotJoin, subject, other, correlationKey: "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject), strength: finished ? 72f : 76f, provenance: provenance, plotKey: plotKey, note: display, resolved: finished); } /// /// Snapshot author + living co-members. Joiners point Other at the author; author points at first peer. /// Finish/cancel resolves every open join for the plot key. /// public static void RecordPlotMembers( Plot plot, bool finished, string provenance = "plot") { if (plot == null) { return; } string plotKey = ""; try { plotKey = plot.getID().ToString(); } catch { plotKey = ""; } if (string.IsNullOrEmpty(plotKey)) { return; } string assetId = ""; try { object asset = plot.GetType().GetField("asset")?.GetValue(plot) ?? plot.GetType().GetProperty("asset")?.GetValue(plot, null); if (asset != null) { object id = asset.GetType().GetField("id")?.GetValue(asset) ?? asset.GetType().GetProperty("id")?.GetValue(asset, null); assetId = id != null ? id.ToString() : ""; } } catch { assetId = ""; } Actor author = null; try { author = plot.getAuthor(); } catch { author = null; } var members = new List(8); LiveEnsemble.CollectPlotUnits(plot, members); if (author != null && author.isAlive() && !members.Contains(author)) { members.Insert(0, author); } Actor peerForAuthor = null; for (int i = 0; i < members.Count; i++) { Actor m = members[i]; if (m == null || m == author) { continue; } try { if (m.isAlive()) { peerForAuthor = m; break; } } catch { // skip } } for (int i = 0; i < members.Count; i++) { Actor member = members[i]; if (member == null) { continue; } try { if (!member.isAlive()) { continue; } } catch { continue; } Actor other = member == author ? peerForAuthor : author; RecordPlot(member, plotKey, assetId, finished, provenance, other); } if (finished) { ResolvePlotJoins(plotKey); } } public static void ResolveWarJoins(string warKey) { ResolveJoinsByKey(warKey, plot: false); } public static void ResolvePlotJoins(string plotKey) { ResolveJoinsByKey(plotKey, plot: true); } private static void ResolveJoinsByKey(string key, bool plot) { if (string.IsNullOrEmpty(key)) { return; } EnsureWorldBound(); bool mutated = false; foreach (KeyValuePair kv in Lives) { LifeSagaLifeMemory life = kv.Value; if (life?.Facts == null) { continue; } for (int i = 0; i < life.Facts.Count; i++) { LifeSagaFact f = life.Facts[i]; if (f == null || f.Resolved) { continue; } bool match = plot ? string.Equals(f.PlotKey, key, StringComparison.Ordinal) : string.Equals(f.WarKey, key, StringComparison.Ordinal); if (!match) { continue; } if (plot) { if (f.Kind == LifeSagaFactKind.PlotJoin || f.Kind == LifeSagaFactKind.PlotEnd) { f.Resolved = true; mutated = true; if (f.Kind == LifeSagaFactKind.PlotJoin) { f.Kind = LifeSagaFactKind.PlotEnd; } } } else if (f.Kind == LifeSagaFactKind.WarJoin || f.Kind == LifeSagaFactKind.WarEnd) { f.Resolved = true; mutated = true; if (f.Kind == LifeSagaFactKind.WarJoin) { f.Kind = LifeSagaFactKind.WarEnd; } } } } if (mutated) { MarkMutated(); } } private static string FormatWarPairNote(string attackerKingdom, string defenderKingdom) { string a = (attackerKingdom ?? "").Trim(); string b = (defenderKingdom ?? "").Trim(); if (!string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b)) { return a + " vs " + b; } return FirstNonEmpty(a, b); } private static string ResolvePlotDisplay(string plotAsset) { if (string.IsNullOrEmpty(plotAsset)) { return ""; } string display = LibraryAssetNames.DisplayName("plots", plotAsset); if (!string.IsNullOrWhiteSpace(display) && !string.Equals(display.Trim(), plotAsset.Trim(), StringComparison.OrdinalIgnoreCase)) { return display.Trim(); } return EventReason.HumanizeId(plotAsset); } internal static string PlotDisplay(string plotAsset) => ResolvePlotDisplay(plotAsset); public static NarrativeEventReceipt RecordFounding( Actor subject, string metaKind, string metaKey, string provenance) { if (subject == null) { return new NarrativeEventReceipt { RejectionReason = "missing_actor" }; } return NarrativeEventRecorder.Founding(subject, metaKind, metaKey, provenance); } internal static LifeSagaFact RecordFoundingFact( Actor subject, string metaKind, string metaKey, string provenance) { if (subject == null) { return null; } return Record( LifeSagaFactKind.Founding, subject, null, correlationKey: "found:" + (metaKind ?? "meta") + ":" + (metaKey ?? EventFeedUtil.SafeId(subject).ToString()), strength: 74f, provenance: provenance, kingdomKey: metaKind == "kingdom" ? metaKey : "", cityKey: metaKind == "city" ? metaKey : "", familyKey: metaKind == "family" ? metaKey : "", clanKey: metaKind == "clan" ? metaKey : "", note: metaKind ?? ""); } public static NarrativeEventReceipt RecordRole( Actor subject, string roleId, string provenance = "happiness") { if (subject == null || string.IsNullOrEmpty(roleId)) { return new NarrativeEventReceipt { RejectionReason = "missing_identity" }; } return NarrativeEventRecorder.Role(subject, roleId, true, provenance); } internal static LifeSagaFact RecordRoleFact( Actor subject, string roleId, bool gained, string provenance = "happiness") { if (subject == null || string.IsNullOrEmpty(roleId)) { return null; } return Record( LifeSagaFactKind.RoleChange, subject, null, correlationKey: "role:" + EventFeedUtil.SafeId(subject) + ":" + roleId + ":" + gained, strength: 68f, provenance: provenance, resolved: !gained, note: roleId); } public static NarrativeEventReceipt RecordHome( Actor subject, bool gained, string provenance = "happiness") { if (subject == null) { return new NarrativeEventReceipt { RejectionReason = "missing_actor" }; } return NarrativeEventRecorder.Home(subject, gained, provenance); } internal static LifeSagaFact RecordHomeFact( Actor subject, bool gained, string provenance = "happiness") { if (subject == null) { return null; } return Record( gained ? LifeSagaFactKind.HomeGained : LifeSagaFactKind.HomeLost, subject, null, correlationKey: "home:" + EventFeedUtil.SafeId(subject) + ":" + (gained ? "gain" : "loss"), strength: 50f, provenance: provenance); } public static LifeSagaIdentity Snapshot(Actor actor) { if (actor == null) { return default; } long id = EventFeedUtil.SafeId(actor); if (id == 0) { return default; } string name = ""; string species = ""; try { name = actor.getName() ?? ""; } catch { name = ""; } try { species = actor.asset != null ? actor.asset.id : ""; } catch { species = ""; } return new LifeSagaIdentity { Id = id, Name = name, SpeciesId = species }; } public static LifeSagaIdentity SnapshotId(long unitId) { if (unitId == 0) { return default; } Actor live = EventFeedUtil.FindAliveById(unitId); if (live != null) { return Snapshot(live); } LifeSagaLifeMemory life = Get(unitId); if (life != null && life.Identity.Id != 0) { return life.Identity; } return new LifeSagaIdentity { Id = unitId }; } public static Actor ResolveAlive(long unitId) => EventFeedUtil.FindAliveById(unitId); public static bool TryGetEarnedRival(long unitId, out LifeSagaFact rivalFact) { rivalFact = null; LifeSagaLifeMemory life = Get(unitId); if (life == null) { return false; } PruneStaleRivals(life); LifeSagaFact bestLiving = null; LifeSagaFact bestAny = null; for (int i = 0; i < life.Facts.Count; i++) { LifeSagaFact f = life.Facts[i]; if (f == null || f.Kind != LifeSagaFactKind.RivalEarned || f.OtherId == 0) { continue; } if (!IsCredibleRival(f)) { continue; } if (bestAny == null || f.Strength > bestAny.Strength || f.At > bestAny.At) { bestAny = f; } if (EventFeedUtil.FindAliveById(f.OtherId) != null) { if (bestLiving == null || f.Strength > bestLiving.Strength || f.At > bestLiving.At) { bestLiving = f; } } } // Cast prefers a living rival; callers that need Legacy can still read FactsFor. rivalFact = bestLiving; return rivalFact != null; } /// True when RivalEarned evidence meets the notable bar (not a single kill-cycle). public static bool IsCredibleRival(LifeSagaFact f) { if (f == null || f.Kind != LifeSagaFactKind.RivalEarned) { return false; } string note = f.Note ?? ""; string prov = f.Provenance ?? ""; if (IsKillCycleProvenance(prov) && note.StartsWith("repeated_encounters:", StringComparison.Ordinal)) { return false; } if (note.StartsWith("repeated_encounters:", StringComparison.Ordinal)) { string num = note.Substring("repeated_encounters:".Length); if (int.TryParse(num, out int count) && count < RivalEncounterThreshold) { return false; } } return true; } private static bool IsKillCycleProvenance(string provenance) { if (string.IsNullOrEmpty(provenance)) { return false; } return string.Equals(provenance, "newKillAction", StringComparison.OrdinalIgnoreCase) || string.Equals(provenance, "die", StringComparison.OrdinalIgnoreCase) || provenance.IndexOf("Kill", StringComparison.OrdinalIgnoreCase) >= 0 || provenance.IndexOf("death", StringComparison.OrdinalIgnoreCase) >= 0; } private static void PruneStaleRivals(LifeSagaLifeMemory life) { if (life?.Facts == null) { return; } bool mutated = false; for (int i = life.Facts.Count - 1; i >= 0; i--) { LifeSagaFact f = life.Facts[i]; if (f != null && f.Kind == LifeSagaFactKind.RivalEarned && !IsCredibleRival(f)) { life.Facts.RemoveAt(i); mutated = true; } } if (mutated) { MarkMutated(); } } public static LifeSagaFact StrongestUnresolved(long unitId) { LifeSagaLifeMemory life = Get(unitId); if (life == null) { return null; } LifeSagaFact best = PickStrongest(life, strongOnly: true); if (best != null) { return best; } // Fallback stakes when no war/plot/role/rival stake exists yet. return PickStrongest(life, strongOnly: false); } /// /// True when an unresolved fact is eligible as a Context hitch candidate /// (primary stake-worthy or fallback-worthy). /// public static bool IsUnresolvedStakeCandidate(LifeSagaFact fact) { if (fact == null || fact.Resolved) { return false; } return IsStakeWorthy(fact) || IsStakeFallbackWorthy(fact); } private static LifeSagaFact PickStrongest(LifeSagaLifeMemory life, bool strongOnly) { LifeSagaFact best = null; for (int i = 0; i < life.Facts.Count; i++) { LifeSagaFact f = life.Facts[i]; if (f == null || f.Resolved) { continue; } if (strongOnly) { if (!IsStakeWorthy(f)) { continue; } } else if (!IsStakeFallbackWorthy(f)) { continue; } if (best == null || f.Strength > best.Strength || (Mathf.Approximately(f.Strength, best.Strength) && f.At > best.At)) { best = f; } } return best; } public static List SelectLegacy(long unitId, int max = 5) { var result = new List(max); LifeSagaLifeMemory life = Get(unitId); if (life == null || max <= 0) { return result; } var usedBuckets = new HashSet(); int parentChildKept = 0; // Prefer diversity across turning-point buckets; never flood Legacy with births. for (int pass = 0; pass < 2 && result.Count < max; pass++) { for (int i = 0; i < life.Facts.Count && result.Count < max; i++) { LifeSagaFact f = life.Facts[i]; if (f == null || !IsLegacyWorthy(f.Kind)) { continue; } int bucket = LegacyBucket(f.Kind); if (pass == 0 && usedBuckets.Contains(bucket)) { continue; } if (ContainsFact(result, f)) { continue; } if (f.Kind == LifeSagaFactKind.ParentChild) { if (parentChildKept >= 1) { continue; } } result.Add(f); usedBuckets.Add(bucket); if (f.Kind == LifeSagaFactKind.ParentChild) { parentChildKept++; } } } return result; } /// Parent-perspective ParentChild facts for this life (excludes mirror child view). public static int CountParentedChildren(long unitId) { LifeSagaLifeMemory life = Get(unitId); if (life == null) { return 0; } var seen = new HashSet(); for (int i = 0; i < life.Facts.Count; i++) { LifeSagaFact f = life.Facts[i]; if (f == null || f.Kind != LifeSagaFactKind.ParentChild || !IsParentPerspective(f)) { continue; } long childId = f.OtherId; if (childId == 0) { childId = f.Other.Id; } if (childId != 0) { seen.Add(childId); } else if (!string.IsNullOrEmpty(f.Other.Name)) { seen.Add(-(i + 1)); } } return seen.Count; } /// Legacy display line: optional world date · beat prose. public static string FormatLegacyLine(LifeSagaFact fact) => SagaProse.FormatLegacyLine(fact); public static string FormatFamilySummary(int childCount) { if (childCount <= 0) { return ""; } if (childCount == 1) { return "Had a child"; } return "Had " + childCount + " children"; } public static bool IsParentPerspective(LifeSagaFact fact) { if (fact == null) { return false; } string key = fact.Key ?? ""; if (key.EndsWith(":mirror", StringComparison.Ordinal)) { return false; } if (key.StartsWith("parent:" + fact.SubjectId + ":", StringComparison.Ordinal)) { return true; } // Harness / non-keyed parent records default to parent voice for the subject life. return !key.Contains(":mirror"); } public static List ChaptersFor(long unitId, int max = 3) { var chapters = new List(max); List legacy = SelectLegacy(unitId, max); for (int i = 0; i < legacy.Count; i++) { LifeSagaFact f = legacy[i]; chapters.Add(new LifeSagaChapter { Kind = ToChapterKind(f.Kind), Line = FormatLegacyLine(f), At = f.At }); } return chapters; } public static string FormatFactLine(LifeSagaFact fact) => SagaProse.FormatFactLine(fact); private static void TryMarkKinKillRival(Actor killer, Actor victim) { if (killer == null || victim == null) { return; } long killerId = EventFeedUtil.SafeId(killer); long victimId = EventFeedUtil.SafeId(victim); // A confirmed kill of an exact MC cast member is stronger antagonist evidence than // ordinary repeated combat. Resolve against the bounded five-character roster and // never infer this edge from a shared city, kingdom, or faction. if (LifeSagaRoster.TryGetFriendlyMcAnchor(victimId, out long anchorMcId) && anchorMcId != killerId) { Actor anchor = EventFeedUtil.FindAliveById(anchorMcId); if (anchor != null) { EarnRival( anchor, killer, "mc_cast_slain:" + victimId, "mc_cast_harm", strength: 95f); return; } } // If victim is close kin of someone on roster/memory, or killer killed kin of a remembered bond. LifeSagaLifeMemory killerLife = Get(killerId); if (killerLife == null) { return; } for (int i = 0; i < killerLife.Facts.Count; i++) { LifeSagaFact f = killerLife.Facts[i]; if (f == null || f.OtherId == 0) { continue; } if (f.Kind == LifeSagaFactKind.BondFormed || f.Kind == LifeSagaFactKind.FriendFormed || f.Kind == LifeSagaFactKind.ParentChild) { // Victim killed someone close to killer? Already handled as Kill. // Killer slew kin of a bonded other: check victim parents/children against bonded id. if (IsCloseKin(victim, f.OtherId)) { Actor rival = EventFeedUtil.FindAliveById(f.OtherId); if (rival != null) { EarnRival(killer, rival, "killed_kin", "kin_kill"); EarnRival(rival, killer, "kin_slain", "kin_kill"); } } } } _ = victimId; } public static bool HarnessProbeMcCastHarm( Actor anchorMc, Actor castMember, Actor attacker, out string detail) { long anchorId = EventFeedUtil.SafeId(anchorMc); long castId = EventFeedUtil.SafeId(castMember); long attackerId = EventFeedUtil.SafeId(attacker); bool exactCast = anchorId != 0 && castId != 0 && attackerId != 0 && LifeSagaRoster.TryGetFriendlyMcAnchor( castId, out long resolvedAnchor) && resolvedAnchor == anchorId; if (exactCast) { TryMarkKinKillRival(attacker, castMember); } bool found = false; float strength = 0f; string note = ""; var facts = FactsFor(anchorId); for (int i = 0; i < facts.Count; i++) { LifeSagaFact fact = facts[i]; if (fact == null || fact.Kind != LifeSagaFactKind.RivalEarned || fact.OtherId != attackerId) { continue; } found = IsCredibleRival(fact); strength = fact.Strength; note = fact.Note ?? ""; break; } bool strongerThanRematch = strength > 85f; detail = "exactCast=" + exactCast + " anchor=" + anchorId + " cast=" + castId + " attacker=" + attackerId + " found=" + found + " strength=" + strength.ToString("0.##") + " note=" + note; return exactCast && found && strongerThanRematch && note.StartsWith("mc_cast_slain:", StringComparison.Ordinal); } private static bool IsCloseKin(Actor actor, long relativeId) { if (actor == null || relativeId == 0) { return false; } try { foreach (Actor p in ActorRelation.EnumerateParents(actor, 2)) { if (EventFeedUtil.SafeId(p) == relativeId) { return true; } } foreach (Actor c in ActorRelation.EnumerateChildren(actor, 8)) { if (EventFeedUtil.SafeId(c) == relativeId) { return true; } } Actor lover = ActorRelation.GetLover(actor); if (lover != null && EventFeedUtil.SafeId(lover) == relativeId) { return true; } } catch { // ignore } return false; } private static void MirrorToOther(LifeSagaFact fact, long otherId) { if (fact == null || otherId == 0) { return; } LifeSagaLifeMemory life = GetOrCreate(otherId); if (life == null) { return; } string mirrorKey = fact.Key + ":mirror"; if (FindByKey(life, mirrorKey) != null) { return; } LifeSagaFactKind mirrorKind = fact.Kind; if (fact.Kind == LifeSagaFactKind.Kill) { mirrorKind = LifeSagaFactKind.Death; } else if (fact.Kind == LifeSagaFactKind.Death) { mirrorKind = LifeSagaFactKind.Kill; } var mirror = new LifeSagaFact { Key = mirrorKey, Kind = mirrorKind, SubjectId = otherId, OtherId = fact.SubjectId, Subject = fact.Other.Id != 0 ? fact.Other : SnapshotId(otherId), Other = fact.Subject, At = fact.At, WorldTime = fact.WorldTime, DateLabel = fact.DateLabel, Strength = fact.Strength, Provenance = fact.Provenance, KingdomKey = fact.KingdomKey, CityKey = fact.CityKey, ClanKey = fact.ClanKey, FamilyKey = fact.FamilyKey, WarKey = fact.WarKey, PlotKey = fact.PlotKey, Resolved = fact.Resolved, Note = fact.Note }; life.Facts.Insert(0, mirror); life.TouchedAt = fact.At; while (life.Facts.Count > MaxFactsPerLife) { life.Facts.RemoveAt(life.Facts.Count - 1); } } private static LifeSagaFact FindByKey(LifeSagaLifeMemory life, string key) { if (life == null || string.IsNullOrEmpty(key)) { return null; } for (int i = 0; i < life.Facts.Count; i++) { if (life.Facts[i] != null && string.Equals(life.Facts[i].Key, key, StringComparison.Ordinal)) { return life.Facts[i]; } } return null; } private static void PruneIfNeeded() { if (Lives.Count < MaxLives) { return; } var ranked = new List(Lives.Count); foreach (KeyValuePair kv in Lives) { ranked.Add(kv.Value); } ranked.Sort((a, b) => { int ap = ProtectScore(a); int bp = ProtectScore(b); int cmp = ap.CompareTo(bp); if (cmp != 0) { return cmp; } return a.TouchedAt.CompareTo(b.TouchedAt); }); int drop = Mathf.Max(1, Lives.Count - MaxLives + 8); for (int i = 0; i < ranked.Count && drop > 0; i++) { LifeSagaLifeMemory life = ranked[i]; if (ProtectScore(life) >= 2) { continue; } Lives.Remove(life.UnitId); drop--; } } private static int ProtectScore(LifeSagaLifeMemory life) { if (life == null) { return 0; } if (LifeSagaRoster.IsMc(life.UnitId)) { return 3; } LifeSagaSlot slot = LifeSagaRoster.Get(life.UnitId); if (slot != null && (slot.Prefer || slot.GameFavorite)) { return 3; } for (int i = 0; i < life.Facts.Count; i++) { if (life.Facts[i] != null && !life.Facts[i].Resolved && IsStakeWorthy(life.Facts[i])) { return 2; } } return 1; } private static string DefaultKey( LifeSagaFactKind kind, long subjectId, long otherId, string warKey, string plotKey) { return kind + ":" + subjectId + ":" + otherId + ":" + (warKey ?? "") + ":" + (plotKey ?? ""); } private static string PairKey(long a, long b) { return a <= b ? a + ":" + b : b + ":" + a; } private static bool IsStakeWorthy(LifeSagaFact fact) { if (fact == null) { return false; } if (fact.McCrossover) { return IsMcCrossoverKind(fact.Kind) || fact.Kind == LifeSagaFactKind.RivalEarned; } return IsStakeWorthyKind(fact.Kind); } private static bool IsStakeWorthyKind(LifeSagaFactKind kind) { switch (kind) { case LifeSagaFactKind.WarJoin: case LifeSagaFactKind.PlotJoin: case LifeSagaFactKind.RivalEarned: case LifeSagaFactKind.BondBroken: case LifeSagaFactKind.HardArcWar: case LifeSagaFactKind.HardArcPlot: case LifeSagaFactKind.HardArcGrief: case LifeSagaFactKind.RoleChange: case LifeSagaFactKind.Founding: return true; default: return false; } } /// /// Weaker unresolved facts used only when no primary stake exists. /// Kind-gated: HardArcCombat, open BondFormed, and McCrossover Kill (Kill already via McCrossover path). /// private static bool IsStakeFallbackWorthy(LifeSagaFact fact) { if (fact == null) { return false; } if (fact.McCrossover && fact.Kind == LifeSagaFactKind.Kill) { return true; } switch (fact.Kind) { case LifeSagaFactKind.HardArcCombat: case LifeSagaFactKind.BondFormed: return true; default: return false; } } private static void TryMarkMcCrossover(LifeSagaFact fact) { if (fact == null || fact.OtherId == 0 || fact.SubjectId == 0) { return; } if (!IsMcCrossoverKind(fact.Kind)) { return; } if (!LifeSagaRoster.IsMc(fact.SubjectId) || !LifeSagaRoster.IsMc(fact.OtherId)) { return; } fact.McCrossover = true; NoteMcCrossoverHeat(fact.SubjectId, fact.OtherId); } private static bool IsMcCrossoverKind(LifeSagaFactKind kind) { switch (kind) { case LifeSagaFactKind.Kill: case LifeSagaFactKind.Death: case LifeSagaFactKind.BondFormed: case LifeSagaFactKind.BondBroken: case LifeSagaFactKind.FriendFormed: case LifeSagaFactKind.ParentChild: case LifeSagaFactKind.RivalEarned: case LifeSagaFactKind.HardArcLove: case LifeSagaFactKind.HardArcGrief: case LifeSagaFactKind.HardArcCombat: case LifeSagaFactKind.HardArcWar: case LifeSagaFactKind.HardArcPlot: case LifeSagaFactKind.WarJoin: case LifeSagaFactKind.PlotJoin: return true; default: return false; } } private static void NoteMcCrossoverHeat(long a, long b) { if (a != 0) { LifeSagaRoster.NoteFeaturedUnit(a, 1.2f); } if (b != 0 && b != a) { LifeSagaRoster.NoteFeaturedUnit(b, 1.2f); } } private static bool IsLegacyWorthy(LifeSagaFactKind kind) { switch (kind) { case LifeSagaFactKind.CombatEncounter: return false; default: return kind != LifeSagaFactKind.Unknown; } } private static int LegacyBucket(LifeSagaFactKind kind) { switch (kind) { case LifeSagaFactKind.Founding: case LifeSagaFactKind.HomeGained: return 0; case LifeSagaFactKind.BondFormed: case LifeSagaFactKind.FriendFormed: case LifeSagaFactKind.HardArcLove: return 1; case LifeSagaFactKind.RoleChange: return 2; case LifeSagaFactKind.Kill: case LifeSagaFactKind.CombatEncounter: case LifeSagaFactKind.RivalEarned: case LifeSagaFactKind.HardArcCombat: case LifeSagaFactKind.WarJoin: case LifeSagaFactKind.WarEnd: case LifeSagaFactKind.HardArcWar: case LifeSagaFactKind.PlotJoin: case LifeSagaFactKind.PlotEnd: case LifeSagaFactKind.HardArcPlot: return 3; case LifeSagaFactKind.BondBroken: case LifeSagaFactKind.HomeLost: case LifeSagaFactKind.Death: case LifeSagaFactKind.HardArcGrief: return 4; case LifeSagaFactKind.ParentChild: // Own bucket so births do not block Bond on the diversity pass, // and so pass-1 fill cannot turn Legacy into child soup. return 5; default: return 6; } } private static bool ContainsFact(List list, LifeSagaFact fact) { for (int i = 0; i < list.Count; i++) { if (list[i] != null && list[i].Key == fact.Key) { return true; } } return false; } private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind) { switch (kind) { case LifeSagaFactKind.Kill: case LifeSagaFactKind.CombatEncounter: case LifeSagaFactKind.RivalEarned: case LifeSagaFactKind.HardArcCombat: return LifeSagaChapterKind.Combat; case LifeSagaFactKind.WarJoin: case LifeSagaFactKind.WarEnd: case LifeSagaFactKind.HardArcWar: return LifeSagaChapterKind.War; case LifeSagaFactKind.PlotJoin: case LifeSagaFactKind.PlotEnd: case LifeSagaFactKind.HardArcPlot: return LifeSagaChapterKind.Plot; case LifeSagaFactKind.BondFormed: case LifeSagaFactKind.HardArcLove: return LifeSagaChapterKind.Love; case LifeSagaFactKind.BondBroken: case LifeSagaFactKind.Death: case LifeSagaFactKind.HardArcGrief: return LifeSagaChapterKind.Grief; default: return LifeSagaChapterKind.Unknown; } } private static string RoleNote(string roleId) { if (string.IsNullOrEmpty(roleId)) { return "Rose in standing"; } if (roleId.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0) { return "Became king"; } if (roleId.IndexOf("leader", StringComparison.OrdinalIgnoreCase) >= 0) { return "Became a leader"; } if (roleId.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0) { return "Became alpha"; } if (roleId.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0 || roleId.IndexOf("clan", StringComparison.OrdinalIgnoreCase) >= 0) { return "Became clan chief"; } if (roleId.IndexOf("captain", StringComparison.OrdinalIgnoreCase) >= 0 || roleId.IndexOf("army", StringComparison.OrdinalIgnoreCase) >= 0) { return "Became army captain"; } return "Role: " + roleId; } 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 ""; } private static void StampWorldDate(out double worldTime, out string dateLabel) { worldTime = 0; dateLabel = ""; try { if (World.world == null) { return; } worldTime = World.world.getCurWorldTime(); int[] raw = Date.getRawDate(worldTime); if (raw != null && raw.Length >= 3) { string monthName = Date.formatMonth(raw[1]); if (string.IsNullOrEmpty(monthName)) { monthName = "m" + raw[1]; } dateLabel = monthName + " " + raw[2]; } } catch { // keep empty } } } public enum LifeSagaFactKind { Unknown, BondFormed, BondBroken, FriendFormed, ParentChild, Kill, Death, CombatEncounter, RivalEarned, WarJoin, WarEnd, PlotJoin, PlotEnd, Founding, RoleChange, HomeGained, HomeLost, HardArcLove, HardArcGrief, HardArcCombat, HardArcWar, HardArcPlot } public struct LifeSagaIdentity { public long Id; public string Name; public string SpeciesId; } public sealed class LifeSagaFact { public string Key = ""; public LifeSagaFactKind Kind; public long SubjectId; public long OtherId; public LifeSagaIdentity Subject; public LifeSagaIdentity Other; public float At; public double WorldTime; public string DateLabel = ""; public float Strength; public string Provenance = ""; public string KingdomKey = ""; public string CityKey = ""; public string ClanKey = ""; public string FamilyKey = ""; public string WarKey = ""; public string PlotKey = ""; public bool Resolved; public string Note = ""; /// Both subject and other were roster MCs when recorded. public bool McCrossover; } public sealed class LifeSagaLifeMemory { public long UnitId; public LifeSagaIdentity Identity; public float TouchedAt; public readonly List Facts = new List(16); }