worldbox-observer-mod/IdleSpectator/Story/LifeSagaMemory.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

2022 lines
55 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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.
/// </summary>
public static class LifeSagaMemory
{
public const int MaxFactsPerLife = 48;
public const int MaxLives = 120;
/// <summary>Fallback when scoring-model omits sagaRivalEncounterThreshold.</summary>
public const int RivalEncounterThresholdDefault = 3;
private const float EncounterDedupeSeconds = 28f;
private static readonly Dictionary<long, LifeSagaLifeMemory> Lives =
new Dictionary<long, LifeSagaLifeMemory>(64);
private static readonly Dictionary<string, int> PairEncounters =
new Dictionary<string, int>(64);
private static readonly Dictionary<string, float> PairEncounterCooldown =
new Dictionary<string, float>(64);
private static object _boundWorld;
private static int _worldEpoch;
public static int WorldEpoch => _worldEpoch;
public static int LifeCount => Lives.Count;
/// <summary>
/// One mutation boundary for every durable fact change. Presentation and relation
/// caches must never outlive the memory state that produced their prose.
/// </summary>
private static void MarkMutated()
{
SagaProse.BumpMemoryRevision();
}
public static int RivalEncounterThreshold
{
get
{
int t = InterestScoringConfig.Story.sagaRivalEncounterThreshold;
return t > 0 ? t : RivalEncounterThresholdDefault;
}
}
public static void EnsureWorldBound()
{
LifeSagaSession.EnsureBound();
}
/// <summary>Called by <see cref="LifeSagaSession"/> after a session reset.</summary>
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<LifeSagaFact> FactsFor(long unitId)
{
LifeSagaLifeMemory life = Get(unitId);
return life != null ? life.Facts : Array.Empty<LifeSagaFact>();
}
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 void RecordBondFormed(Actor a, Actor b, string provenance = "set_lover")
{
if (a == null || b == 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);
NarrativeDevelopmentRouter.BondFormed(a, b, provenance);
}
public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover")
{
if (survivor == null)
{
return;
}
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;
}
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);
NarrativeDevelopmentRouter.BondBroken(survivor, former, provenance);
}
public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child")
{
if (parent == null || child == null)
{
return;
}
long parentId = EventFeedUtil.SafeId(parent);
long childId = EventFeedUtil.SafeId(child);
Record(
LifeSagaFactKind.ParentChild,
parent,
child,
correlationKey: "parent:" + parentId + ":" + childId,
strength: 65f,
provenance: provenance);
NarrativeDevelopmentRouter.ChildBorn(parent, child, provenance);
}
public static void RecordFriendFormed(Actor a, Actor b, string provenance = "set_best_friend")
{
if (a == null || b == null)
{
return;
}
Record(
LifeSagaFactKind.FriendFormed,
a,
b,
correlationKey: "friend:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)),
strength: 55f,
provenance: provenance);
NarrativeDevelopmentRouter.FriendFormed(a, b, provenance);
}
public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction")
{
if (killer == null || victim == null)
{
return;
}
long killerId = EventFeedUtil.SafeId(killer);
long victimId = EventFeedUtil.SafeId(victim);
Record(
LifeSagaFactKind.Kill,
killer,
victim,
correlationKey: "kill:" + killerId + ":" + victimId + ":"
+ Time.unscaledTime.ToString("0.###"),
strength: 80f,
provenance: provenance);
NarrativeDevelopmentRouter.Kill(killer, victim, provenance);
// 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);
}
}
public static void RecordDeath(Actor victim, Actor killer, string attackType, string provenance = "die")
{
if (victim == null)
{
return;
}
long victimId = EventFeedUtil.SafeId(victim);
Record(
LifeSagaFactKind.Death,
victim,
killer,
correlationKey: "death:" + victimId,
strength: 90f,
provenance: provenance,
resolved: true,
note: attackType ?? "");
NarrativeDevelopmentRouter.Death(victim, killer, attackType, provenance);
if (killer != null)
{
long killerId = EventFeedUtil.SafeId(killer);
if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId))
{
NoteMcCrossoverHeat(killerId, victimId);
}
}
}
/// <summary>
/// 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.
/// </summary>
public static void NoteCombatEncounter(Actor a, Actor b, string provenance = "combat")
{
NoteCombatEncounterCore(a, b, provenance, bypassCooldown: false);
}
/// <summary>Harness: count distinct living rematches without waiting out the dedupe window.</summary>
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)
{
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: 85f,
provenance: provenance,
note: evidence ?? "");
if (fact != null && LifeSagaRoster.IsMc(subjectId) && LifeSagaRoster.IsMc(rivalId))
{
fact.McCrossover = true;
NoteMcCrossoverHeat(subjectId, rivalId);
}
}
/// <summary>
/// Opposing sides of the same war: durable rivalry when both units are known.
/// </summary>
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);
}
/// <summary>
/// Opposing sides of the same plot: durable rivalry when both units are known.
/// </summary>
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 void RecordWar(
Actor subject,
string warKey,
string kingdomKey,
bool ended,
string provenance = "war",
Actor other = null,
string note = "")
{
if (subject == null || string.IsNullOrEmpty(warKey))
{
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 ?? "");
NarrativeDevelopmentRouter.War(subject, other, warKey, ended, provenance);
}
/// <summary>
/// Snapshot both kingdom principals for a war tip/object. On end, resolve all open joins for the war key.
/// </summary>
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 void RecordPlot(
Actor subject,
string plotKey,
string plotAsset,
bool finished,
string provenance = "plot",
Actor other = null)
{
if (subject == null || string.IsNullOrEmpty(plotKey))
{
return;
}
string display = ResolvePlotDisplay(plotAsset);
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);
NarrativeDevelopmentRouter.Plot(subject, other, plotKey, finished, provenance);
}
/// <summary>
/// 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.
/// </summary>
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<Actor>(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<long, LifeSagaLifeMemory> 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);
}
public static void RecordFounding(Actor subject, string metaKind, string metaKey, string provenance)
{
if (subject == 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 ?? "");
NarrativeDevelopmentRouter.Founding(subject, metaKind, metaKey, provenance);
}
public static void RecordRole(Actor subject, string roleId, string provenance = "happiness")
{
if (subject == null || string.IsNullOrEmpty(roleId))
{
return;
}
Record(
LifeSagaFactKind.RoleChange,
subject,
null,
correlationKey: "role:" + EventFeedUtil.SafeId(subject) + ":" + roleId,
strength: 68f,
provenance: provenance,
note: roleId);
NarrativeDevelopmentRouter.Role(subject, roleId, true, provenance);
}
public static void RecordHome(Actor subject, bool gained, string provenance = "happiness")
{
if (subject == null)
{
return;
}
Record(
gained ? LifeSagaFactKind.HomeGained : LifeSagaFactKind.HomeLost,
subject,
null,
correlationKey: "home:" + EventFeedUtil.SafeId(subject) + ":" + (gained ? "gain" : "loss") + ":"
+ Time.unscaledTime.ToString("0.###"),
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;
}
/// <summary>True when RivalEarned evidence meets the notable bar (not a single kill-cycle).</summary>
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);
}
/// <summary>
/// True when an unresolved fact is eligible as a Context hitch candidate
/// (primary stake-worthy or fallback-worthy).
/// </summary>
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<LifeSagaFact> SelectLegacy(long unitId, int max = 5)
{
var result = new List<LifeSagaFact>(max);
LifeSagaLifeMemory life = Get(unitId);
if (life == null || max <= 0)
{
return result;
}
var usedBuckets = new HashSet<int>();
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;
}
/// <summary>Parent-perspective ParentChild facts for this life (excludes mirror child view).</summary>
public static int CountParentedChildren(long unitId)
{
LifeSagaLifeMemory life = Get(unitId);
if (life == null)
{
return 0;
}
var seen = new HashSet<long>();
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;
}
/// <summary>Legacy display line: optional world date · beat prose.</summary>
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<LifeSagaChapter> ChaptersFor(long unitId, int max = 3)
{
var chapters = new List<LifeSagaChapter>(max);
List<LifeSagaFact> 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);
// 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;
}
long victimId = EventFeedUtil.SafeId(victim);
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;
}
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<LifeSagaLifeMemory>(Lives.Count);
foreach (KeyValuePair<long, LifeSagaLifeMemory> 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;
}
}
/// <summary>
/// Weaker unresolved facts used only when no primary stake exists.
/// Kind-gated: HardArcCombat, open BondFormed, and McCrossover Kill (Kill already via McCrossover path).
/// </summary>
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<LifeSagaFact> 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 = "";
/// <summary>Both subject and other were roster MCs when recorded.</summary>
public bool McCrossover;
}
public sealed class LifeSagaLifeMemory
{
public long UnitId;
public LifeSagaIdentity Identity;
public float TouchedAt;
public readonly List<LifeSagaFact> Facts = new List<LifeSagaFact>(16);
}