- 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
1332 lines
37 KiB
C#
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);
|
|
}
|