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

1332 lines
37 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;
public const int RivalEncounterThreshold = 2;
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 object _boundWorld;
private static int _worldEpoch;
public static int WorldEpoch => _worldEpoch;
public static int LifeCount => Lives.Count;
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();
_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);
}
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);
NoteCombatEncounter(killer, victim, provenance);
TryMarkKinKillRival(killer, victim);
}
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)
{
NoteCombatEncounter(killer, victim, provenance);
}
}
public static void NoteCombatEncounter(Actor a, Actor b, string provenance = "combat")
{
if (a == null || b == null)
{
return;
}
long idA = EventFeedUtil.SafeId(a);
long idB = EventFeedUtil.SafeId(b);
if (idA == 0 || idB == 0 || idA == idB)
{
return;
}
string pair = PairKey(idA, idB);
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);
if (count >= RivalEncounterThreshold)
{
EarnRival(a, b, "repeated_encounters:" + count, 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);
Record(
LifeSagaFactKind.RivalEarned,
subject,
rival,
correlationKey: "rival:" + PairKey(subjectId, rivalId),
strength: 85f,
provenance: provenance,
note: evidence ?? "");
}
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;
}
for (int i = 0; i < life.Facts.Count; i++)
{
LifeSagaFact f = life.Facts[i];
if (f != null && f.Kind == LifeSagaFactKind.RivalEarned && f.OtherId != 0)
{
rivalFact = f;
return true;
}
}
return false;
}
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.Kind))
{
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:
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:
return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
case LifeSagaFactKind.Death:
return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other;
case LifeSagaFactKind.RivalEarned:
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].Kind))
{
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(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 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 = "";
}
public sealed class LifeSagaLifeMemory
{
public long UnitId;
public LifeSagaIdentity Identity;
public float TouchedAt;
public readonly List<LifeSagaFact> Facts = new List<LifeSagaFact>(16);
}