worldbox-observer-mod/IdleSpectator/Story/LifeSagaMemory.cs
DazedAnon 3941dc33e5 feat(saga): bias director onto MCs and note cross-MC beats
- Prefer/MC/cast win moderate tip gaps; combat follow and ambient cut-in follow roster
- Dual-light rail when tip touches multiple MCs; record MC↔MC crossover memory
- Earn rivals only from rematches, kin, or war/plot opposition (not single kills)
- Add harness coverage for gap, cast, rival, cross-MC, and ambient cut paths
2026-07-22 05:22:16 -05:00

1698 lines
48 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;
public static int RivalEncounterThreshold
{
get
{
int t = InterestScoringConfig.Story.sagaRivalEncounterThreshold;
return t > 0 ? t : RivalEncounterThresholdDefault;
}
}
public static void EnsureWorldBound()
{
object world = null;
try
{
world = World.world;
}
catch
{
world = null;
}
if (ReferenceEquals(world, _boundWorld))
{
return;
}
ClearSession();
_boundWorld = world;
_worldEpoch++;
}
public static void ClearSession()
{
Lives.Clear();
PairEncounters.Clear();
PairEncounterCooldown.Clear();
_boundWorld = null;
}
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;
}
life.TouchedAt = now;
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;
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);
}
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);
}
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);
}
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);
}
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);
// 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 ?? "");
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")
{
if (subject == null || string.IsNullOrEmpty(warKey))
{
return;
}
Record(
ended ? LifeSagaFactKind.WarEnd : LifeSagaFactKind.WarJoin,
subject,
null,
correlationKey: "war:" + warKey + ":" + EventFeedUtil.SafeId(subject),
strength: ended ? 70f : 78f,
provenance: provenance,
kingdomKey: kingdomKey,
warKey: warKey,
resolved: ended);
}
public static void RecordPlot(
Actor subject,
string plotKey,
string plotAsset,
bool finished,
string provenance = "plot")
{
if (subject == null || string.IsNullOrEmpty(plotKey))
{
return;
}
Record(
finished ? LifeSagaFactKind.PlotEnd : LifeSagaFactKind.PlotJoin,
subject,
null,
correlationKey: "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject),
strength: finished ? 72f : 76f,
provenance: provenance,
plotKey: plotKey,
note: plotAsset ?? "",
resolved: finished);
}
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 ?? "");
}
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);
}
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 void RecordHardArc(
long unitId,
StoryArcKind arcKind,
string note,
long relatedId = 0,
string provenance = "story_planner")
{
if (unitId == 0)
{
return;
}
LifeSagaIdentity subject = SnapshotId(unitId);
LifeSagaIdentity other = relatedId != 0 ? SnapshotId(relatedId) : default;
LifeSagaFactKind kind = HardArcKind(arcKind);
RecordIds(
kind,
unitId,
subject,
relatedId,
other,
correlationKey: "arc:" + unitId + ":" + arcKind + ":" + (note ?? "").GetHashCode(),
strength: 60f,
provenance: provenance,
note: note ?? "");
}
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;
}
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);
}
}
}
public static LifeSagaFact StrongestUnresolved(long unitId)
{
LifeSagaLifeMemory life = Get(unitId);
if (life == null)
{
return null;
}
LifeSagaFact best = null;
for (int i = 0; i < life.Facts.Count; i++)
{
LifeSagaFact f = life.Facts[i];
if (f == null || f.Resolved)
{
continue;
}
if (!IsStakeWorthy(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>();
// Prefer diversity across turning-point buckets.
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;
}
result.Add(f);
usedBuckets.Add(bucket);
}
}
return result;
}
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 = FormatFactLine(f),
At = f.At
});
}
return chapters;
}
public static string FormatFactLine(LifeSagaFact fact)
{
if (fact == null)
{
return "";
}
string other = !string.IsNullOrEmpty(fact.Other.Name) ? fact.Other.Name : "";
switch (fact.Kind)
{
case LifeSagaFactKind.BondFormed:
if (fact.McCrossover)
{
return string.IsNullOrEmpty(other)
? "Bound to a fellow saga hero"
: "Bound to fellow saga hero " + other;
}
return string.IsNullOrEmpty(other) ? "Found love" : "Bound with " + other;
case LifeSagaFactKind.BondBroken:
return string.IsNullOrEmpty(other) ? "Lost a lover" : "Lost " + other;
case LifeSagaFactKind.FriendFormed:
return string.IsNullOrEmpty(other) ? "Made a true friend" : "Befriended " + other;
case LifeSagaFactKind.ParentChild:
return string.IsNullOrEmpty(other) ? "A child was born" : "Child " + other;
case LifeSagaFactKind.Kill:
if (fact.McCrossover)
{
return string.IsNullOrEmpty(other)
? "Slew a fellow saga hero"
: "Slew fellow saga hero " + other;
}
return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
case LifeSagaFactKind.Death:
if (fact.McCrossover)
{
return string.IsNullOrEmpty(other)
? "Fallen to a fellow saga hero"
: "Slain by fellow saga hero " + other;
}
return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other;
case LifeSagaFactKind.RivalEarned:
if (!string.IsNullOrEmpty(fact.Note))
{
if (fact.Note.StartsWith("opposed_in_war:", StringComparison.Ordinal))
{
return string.IsNullOrEmpty(other)
? "Opposed in war"
: "Opposed " + other + " in war";
}
if (fact.Note.StartsWith("opposed_in_plot:", StringComparison.Ordinal))
{
return string.IsNullOrEmpty(other)
? "Opposed in a plot"
: "Opposed " + other + " in a plot";
}
if (fact.Note.StartsWith("killed_kin", StringComparison.Ordinal)
|| fact.Note.StartsWith("kin_slain", StringComparison.Ordinal))
{
return string.IsNullOrEmpty(other)
? "Blood feud over kin"
: "Blood feud with " + other;
}
if (fact.Note.StartsWith("mc_rematch:", StringComparison.Ordinal)
|| fact.Note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
{
return string.IsNullOrEmpty(other)
? "Clashed again and again"
: "Clashed thrice with " + other;
}
}
return string.IsNullOrEmpty(other) ? "Earned a rival" : "Rivalry with " + other;
case LifeSagaFactKind.WarJoin:
return "Entered a war";
case LifeSagaFactKind.WarEnd:
return "War ended";
case LifeSagaFactKind.PlotJoin:
return "Joined a plot";
case LifeSagaFactKind.PlotEnd:
return "Plot resolved";
case LifeSagaFactKind.Founding:
return !string.IsNullOrEmpty(fact.Note) ? "Founded " + fact.Note : "Founded a new home";
case LifeSagaFactKind.RoleChange:
return RoleNote(fact.Note);
case LifeSagaFactKind.HomeGained:
return "Found a home";
case LifeSagaFactKind.HomeLost:
return "Lost a home";
case LifeSagaFactKind.HardArcLove:
return FirstNonEmpty(fact.Note, "A relationship changed them");
case LifeSagaFactKind.HardArcGrief:
return FirstNonEmpty(fact.Note, "Carrying a loss");
case LifeSagaFactKind.HardArcCombat:
return FirstNonEmpty(fact.Note, "A fight marked them");
case LifeSagaFactKind.HardArcWar:
return FirstNonEmpty(fact.Note, "War closed in");
case LifeSagaFactKind.HardArcPlot:
return FirstNonEmpty(fact.Note, "Entangled in a plot");
case LifeSagaFactKind.CombatEncounter:
return string.IsNullOrEmpty(other) ? "Fought again" : "Clashed with " + other;
default:
return FirstNonEmpty(fact.Note, "A turning point");
}
}
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;
}
}
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)
{
CausalHeat.NoteFeatured(a, 1.15f);
LifeSagaRoster.NoteFeaturedUnit(a, 1.2f);
}
if (b != 0 && b != a)
{
CausalHeat.NoteFeatured(b, 1.15f);
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.ParentChild:
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;
default:
return 5;
}
}
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 LifeSagaFactKind HardArcKind(StoryArcKind arcKind)
{
switch (arcKind)
{
case StoryArcKind.Love:
return LifeSagaFactKind.HardArcLove;
case StoryArcKind.Grief:
return LifeSagaFactKind.HardArcGrief;
case StoryArcKind.CombatDuel:
case StoryArcKind.CombatMass:
return LifeSagaFactKind.HardArcCombat;
case StoryArcKind.WarFront:
return LifeSagaFactKind.HardArcWar;
case StoryArcKind.Plot:
return LifeSagaFactKind.HardArcPlot;
default:
return LifeSagaFactKind.Unknown;
}
}
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";
}
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);
}