- Compose Identity, Beat, and Context without mutating camera labels - Move Cast and Legacy details into a hover-only panel - Centralize relation, title, stake, and legacy prose - Make rail clicks toggle Prefer without pinning a Saga subject - Add harness coverage for caption composition and hover behavior
2036 lines
56 KiB
C#
2036 lines
56 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()
|
|
{
|
|
LifeSagaSession.EnsureBound();
|
|
}
|
|
|
|
/// <summary>Called by <see cref="LifeSagaSession"/> after a session reset.</summary>
|
|
public static void NoteBoundWorld(object world)
|
|
{
|
|
_boundWorld = world;
|
|
}
|
|
|
|
public static void ClearSession()
|
|
{
|
|
Lives.Clear();
|
|
PairEncounters.Clear();
|
|
PairEncounterCooldown.Clear();
|
|
_boundWorld = null;
|
|
_worldEpoch++;
|
|
SagaProse.BumpMemoryRevision();
|
|
}
|
|
|
|
public static void Clear() => ClearSession();
|
|
|
|
public static LifeSagaLifeMemory Get(long unitId)
|
|
{
|
|
if (unitId == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
EnsureWorldBound();
|
|
return Lives.TryGetValue(unitId, out LifeSagaLifeMemory life) ? life : null;
|
|
}
|
|
|
|
public static LifeSagaLifeMemory GetOrCreate(long unitId)
|
|
{
|
|
if (unitId == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
EnsureWorldBound();
|
|
if (Lives.TryGetValue(unitId, out LifeSagaLifeMemory existing))
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
PruneIfNeeded();
|
|
var life = new LifeSagaLifeMemory { UnitId = unitId };
|
|
Lives[unitId] = life;
|
|
return life;
|
|
}
|
|
|
|
public static bool HasFacts(long unitId)
|
|
{
|
|
LifeSagaLifeMemory life = Get(unitId);
|
|
return life != null && life.Facts.Count > 0;
|
|
}
|
|
|
|
public static IReadOnlyList<LifeSagaFact> FactsFor(long unitId)
|
|
{
|
|
LifeSagaLifeMemory life = Get(unitId);
|
|
return life != null ? life.Facts : Array.Empty<LifeSagaFact>();
|
|
}
|
|
|
|
public static LifeSagaFact Record(
|
|
LifeSagaFactKind kind,
|
|
Actor subject,
|
|
Actor other = null,
|
|
string correlationKey = null,
|
|
float strength = 50f,
|
|
string provenance = "",
|
|
string kingdomKey = "",
|
|
string cityKey = "",
|
|
string clanKey = "",
|
|
string familyKey = "",
|
|
string warKey = "",
|
|
string plotKey = "",
|
|
bool resolved = false,
|
|
string note = "")
|
|
{
|
|
if (subject == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
long subjectId = EventFeedUtil.SafeId(subject);
|
|
if (subjectId == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
LifeSagaIdentity subjectSnap = Snapshot(subject);
|
|
LifeSagaIdentity otherSnap = Snapshot(other);
|
|
return RecordIds(
|
|
kind,
|
|
subjectId,
|
|
subjectSnap,
|
|
otherSnap.Id,
|
|
otherSnap,
|
|
correlationKey,
|
|
strength,
|
|
provenance,
|
|
kingdomKey,
|
|
cityKey,
|
|
clanKey,
|
|
familyKey,
|
|
warKey,
|
|
plotKey,
|
|
resolved,
|
|
note);
|
|
}
|
|
|
|
public static LifeSagaFact RecordIds(
|
|
LifeSagaFactKind kind,
|
|
long subjectId,
|
|
LifeSagaIdentity subject,
|
|
long otherId,
|
|
LifeSagaIdentity other,
|
|
string correlationKey = null,
|
|
float strength = 50f,
|
|
string provenance = "",
|
|
string kingdomKey = "",
|
|
string cityKey = "",
|
|
string clanKey = "",
|
|
string familyKey = "",
|
|
string warKey = "",
|
|
string plotKey = "",
|
|
bool resolved = false,
|
|
string note = "")
|
|
{
|
|
if (subjectId == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
EnsureWorldBound();
|
|
LifeSagaLifeMemory life = GetOrCreate(subjectId);
|
|
if (life == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (subject.Id != 0)
|
|
{
|
|
life.Identity = subject;
|
|
}
|
|
|
|
string key = string.IsNullOrEmpty(correlationKey)
|
|
? DefaultKey(kind, subjectId, otherId, warKey, plotKey)
|
|
: correlationKey;
|
|
|
|
LifeSagaFact existing = FindByKey(life, key);
|
|
float now = Time.unscaledTime;
|
|
StampWorldDate(out double worldTime, out string dateLabel);
|
|
if (existing != null)
|
|
{
|
|
existing.Strength = Mathf.Max(existing.Strength, strength);
|
|
existing.Resolved = resolved;
|
|
existing.At = now;
|
|
existing.WorldTime = worldTime;
|
|
existing.DateLabel = dateLabel;
|
|
if (!string.IsNullOrEmpty(note))
|
|
{
|
|
existing.Note = note;
|
|
}
|
|
|
|
if (other.Id != 0 && existing.Other.Id == 0)
|
|
{
|
|
existing.Other = other;
|
|
existing.OtherId = other.Id;
|
|
}
|
|
|
|
// Keep join/end kinds aligned when the same correlation key is reused.
|
|
if (kind == LifeSagaFactKind.WarEnd || kind == LifeSagaFactKind.PlotEnd)
|
|
{
|
|
existing.Kind = kind;
|
|
existing.Resolved = true;
|
|
}
|
|
|
|
life.TouchedAt = now;
|
|
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;
|
|
SagaProse.BumpMemoryRevision();
|
|
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",
|
|
Actor other = null,
|
|
string note = "")
|
|
{
|
|
if (subject == null || string.IsNullOrEmpty(warKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Record(
|
|
ended ? LifeSagaFactKind.WarEnd : LifeSagaFactKind.WarJoin,
|
|
subject,
|
|
other,
|
|
correlationKey: "war:" + warKey + ":" + EventFeedUtil.SafeId(subject),
|
|
strength: ended ? 70f : 78f,
|
|
provenance: provenance,
|
|
kingdomKey: kingdomKey,
|
|
warKey: warKey,
|
|
resolved: ended,
|
|
note: note ?? "");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot both kingdom principals for a war tip/object. On end, resolve all open joins for the war key.
|
|
/// </summary>
|
|
public static void RecordWarPrincipals(
|
|
Actor attackerPrincipal,
|
|
Actor defenderPrincipal,
|
|
string warKey,
|
|
string attackerKingdom,
|
|
string defenderKingdom,
|
|
bool ended,
|
|
string provenance = "war")
|
|
{
|
|
if (string.IsNullOrEmpty(warKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string pairNote = FormatWarPairNote(attackerKingdom, defenderKingdom);
|
|
if (attackerPrincipal != null)
|
|
{
|
|
try
|
|
{
|
|
if (attackerPrincipal.isAlive())
|
|
{
|
|
RecordWar(
|
|
attackerPrincipal,
|
|
warKey,
|
|
attackerKingdom ?? "",
|
|
ended,
|
|
provenance,
|
|
other: defenderPrincipal,
|
|
note: pairNote);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// skip dead/invalid
|
|
}
|
|
}
|
|
|
|
if (defenderPrincipal != null)
|
|
{
|
|
try
|
|
{
|
|
if (defenderPrincipal.isAlive())
|
|
{
|
|
RecordWar(
|
|
defenderPrincipal,
|
|
warKey,
|
|
defenderKingdom ?? "",
|
|
ended,
|
|
provenance,
|
|
other: attackerPrincipal,
|
|
note: pairNote);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// skip dead/invalid
|
|
}
|
|
}
|
|
|
|
if (ended)
|
|
{
|
|
ResolveWarJoins(warKey);
|
|
}
|
|
}
|
|
|
|
public static void RecordPlot(
|
|
Actor subject,
|
|
string plotKey,
|
|
string plotAsset,
|
|
bool finished,
|
|
string provenance = "plot",
|
|
Actor other = null)
|
|
{
|
|
if (subject == null || string.IsNullOrEmpty(plotKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string display = ResolvePlotDisplay(plotAsset);
|
|
Record(
|
|
finished ? LifeSagaFactKind.PlotEnd : LifeSagaFactKind.PlotJoin,
|
|
subject,
|
|
other,
|
|
correlationKey: "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject),
|
|
strength: finished ? 72f : 76f,
|
|
provenance: provenance,
|
|
plotKey: plotKey,
|
|
note: display,
|
|
resolved: finished);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot author + living co-members. Joiners point Other at the author; author points at first peer.
|
|
/// Finish/cancel resolves every open join for the plot key.
|
|
/// </summary>
|
|
public static void RecordPlotMembers(
|
|
Plot plot,
|
|
bool finished,
|
|
string provenance = "plot")
|
|
{
|
|
if (plot == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string plotKey = "";
|
|
try
|
|
{
|
|
plotKey = plot.getID().ToString();
|
|
}
|
|
catch
|
|
{
|
|
plotKey = "";
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(plotKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string assetId = "";
|
|
try
|
|
{
|
|
object asset = plot.GetType().GetField("asset")?.GetValue(plot)
|
|
?? plot.GetType().GetProperty("asset")?.GetValue(plot, null);
|
|
if (asset != null)
|
|
{
|
|
object id = asset.GetType().GetField("id")?.GetValue(asset)
|
|
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
|
|
assetId = id != null ? id.ToString() : "";
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
assetId = "";
|
|
}
|
|
|
|
Actor author = null;
|
|
try
|
|
{
|
|
author = plot.getAuthor();
|
|
}
|
|
catch
|
|
{
|
|
author = null;
|
|
}
|
|
|
|
var members = new List<Actor>(8);
|
|
LiveEnsemble.CollectPlotUnits(plot, members);
|
|
if (author != null && author.isAlive() && !members.Contains(author))
|
|
{
|
|
members.Insert(0, author);
|
|
}
|
|
|
|
Actor peerForAuthor = null;
|
|
for (int i = 0; i < members.Count; i++)
|
|
{
|
|
Actor m = members[i];
|
|
if (m == null || m == author)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (m.isAlive())
|
|
{
|
|
peerForAuthor = m;
|
|
break;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// skip
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < members.Count; i++)
|
|
{
|
|
Actor member = members[i];
|
|
if (member == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!member.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Actor other = member == author ? peerForAuthor : author;
|
|
RecordPlot(member, plotKey, assetId, finished, provenance, other);
|
|
}
|
|
|
|
if (finished)
|
|
{
|
|
ResolvePlotJoins(plotKey);
|
|
}
|
|
}
|
|
|
|
public static void ResolveWarJoins(string warKey)
|
|
{
|
|
ResolveJoinsByKey(warKey, plot: false);
|
|
}
|
|
|
|
public static void ResolvePlotJoins(string plotKey)
|
|
{
|
|
ResolveJoinsByKey(plotKey, plot: true);
|
|
}
|
|
|
|
private static void ResolveJoinsByKey(string key, bool plot)
|
|
{
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnsureWorldBound();
|
|
foreach (KeyValuePair<long, LifeSagaLifeMemory> kv in Lives)
|
|
{
|
|
LifeSagaLifeMemory life = kv.Value;
|
|
if (life?.Facts == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for (int i = 0; i < life.Facts.Count; i++)
|
|
{
|
|
LifeSagaFact f = life.Facts[i];
|
|
if (f == null || f.Resolved)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bool match = plot
|
|
? string.Equals(f.PlotKey, key, StringComparison.Ordinal)
|
|
: string.Equals(f.WarKey, key, StringComparison.Ordinal);
|
|
if (!match)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (plot)
|
|
{
|
|
if (f.Kind == LifeSagaFactKind.PlotJoin || f.Kind == LifeSagaFactKind.PlotEnd)
|
|
{
|
|
f.Resolved = true;
|
|
if (f.Kind == LifeSagaFactKind.PlotJoin)
|
|
{
|
|
f.Kind = LifeSagaFactKind.PlotEnd;
|
|
}
|
|
}
|
|
}
|
|
else if (f.Kind == LifeSagaFactKind.WarJoin || f.Kind == LifeSagaFactKind.WarEnd)
|
|
{
|
|
f.Resolved = true;
|
|
if (f.Kind == LifeSagaFactKind.WarJoin)
|
|
{
|
|
f.Kind = LifeSagaFactKind.WarEnd;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string FormatWarPairNote(string attackerKingdom, string defenderKingdom)
|
|
{
|
|
string a = (attackerKingdom ?? "").Trim();
|
|
string b = (defenderKingdom ?? "").Trim();
|
|
if (!string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b))
|
|
{
|
|
return a + " vs " + b;
|
|
}
|
|
|
|
return FirstNonEmpty(a, b);
|
|
}
|
|
|
|
private static string ResolvePlotDisplay(string plotAsset)
|
|
{
|
|
if (string.IsNullOrEmpty(plotAsset))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string display = LibraryAssetNames.DisplayName("plots", plotAsset);
|
|
if (!string.IsNullOrWhiteSpace(display)
|
|
&& !string.Equals(display.Trim(), plotAsset.Trim(), StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return display.Trim();
|
|
}
|
|
|
|
return EventReason.HumanizeId(plotAsset);
|
|
}
|
|
|
|
public static void RecordFounding(Actor subject, string metaKind, string metaKey, string provenance)
|
|
{
|
|
if (subject == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Record(
|
|
LifeSagaFactKind.Founding,
|
|
subject,
|
|
null,
|
|
correlationKey: "found:" + (metaKind ?? "meta") + ":" + (metaKey ?? EventFeedUtil.SafeId(subject).ToString()),
|
|
strength: 74f,
|
|
provenance: provenance,
|
|
kingdomKey: metaKind == "kingdom" ? metaKey : "",
|
|
cityKey: metaKind == "city" ? metaKey : "",
|
|
familyKey: metaKind == "family" ? metaKey : "",
|
|
clanKey: metaKind == "clan" ? metaKey : "",
|
|
note: metaKind ?? "");
|
|
}
|
|
|
|
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 = PickStrongest(life, strongOnly: true);
|
|
if (best != null)
|
|
{
|
|
return best;
|
|
}
|
|
|
|
// Fallback stakes when no war/plot/role/rival stake exists yet.
|
|
return PickStrongest(life, strongOnly: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when an unresolved fact is eligible as a Context hitch candidate
|
|
/// (primary stake-worthy or fallback-worthy).
|
|
/// </summary>
|
|
public static bool IsUnresolvedStakeCandidate(LifeSagaFact fact)
|
|
{
|
|
if (fact == null || fact.Resolved)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return IsStakeWorthy(fact) || IsStakeFallbackWorthy(fact);
|
|
}
|
|
|
|
private static LifeSagaFact PickStrongest(LifeSagaLifeMemory life, bool strongOnly)
|
|
{
|
|
LifeSagaFact best = null;
|
|
for (int i = 0; i < life.Facts.Count; i++)
|
|
{
|
|
LifeSagaFact f = life.Facts[i];
|
|
if (f == null || f.Resolved)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (strongOnly)
|
|
{
|
|
if (!IsStakeWorthy(f))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
else if (!IsStakeFallbackWorthy(f))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (best == null || f.Strength > best.Strength
|
|
|| (Mathf.Approximately(f.Strength, best.Strength) && f.At > best.At))
|
|
{
|
|
best = f;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
public static List<LifeSagaFact> SelectLegacy(long unitId, int max = 5)
|
|
{
|
|
var result = new List<LifeSagaFact>(max);
|
|
LifeSagaLifeMemory life = Get(unitId);
|
|
if (life == null || max <= 0)
|
|
{
|
|
return result;
|
|
}
|
|
|
|
var usedBuckets = new HashSet<int>();
|
|
int parentChildKept = 0;
|
|
// Prefer diversity across turning-point buckets; never flood Legacy with births.
|
|
for (int pass = 0; pass < 2 && result.Count < max; pass++)
|
|
{
|
|
for (int i = 0; i < life.Facts.Count && result.Count < max; i++)
|
|
{
|
|
LifeSagaFact f = life.Facts[i];
|
|
if (f == null || !IsLegacyWorthy(f.Kind))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int bucket = LegacyBucket(f.Kind);
|
|
if (pass == 0 && usedBuckets.Contains(bucket))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (ContainsFact(result, f))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (f.Kind == LifeSagaFactKind.ParentChild)
|
|
{
|
|
if (parentChildKept >= 1)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
result.Add(f);
|
|
usedBuckets.Add(bucket);
|
|
if (f.Kind == LifeSagaFactKind.ParentChild)
|
|
{
|
|
parentChildKept++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Parent-perspective ParentChild facts for this life (excludes mirror child view).</summary>
|
|
public static int CountParentedChildren(long unitId)
|
|
{
|
|
LifeSagaLifeMemory life = Get(unitId);
|
|
if (life == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var seen = new HashSet<long>();
|
|
for (int i = 0; i < life.Facts.Count; i++)
|
|
{
|
|
LifeSagaFact f = life.Facts[i];
|
|
if (f == null || f.Kind != LifeSagaFactKind.ParentChild || !IsParentPerspective(f))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
long childId = f.OtherId;
|
|
if (childId == 0)
|
|
{
|
|
childId = f.Other.Id;
|
|
}
|
|
|
|
if (childId != 0)
|
|
{
|
|
seen.Add(childId);
|
|
}
|
|
else if (!string.IsNullOrEmpty(f.Other.Name))
|
|
{
|
|
seen.Add(-(i + 1));
|
|
}
|
|
}
|
|
|
|
return seen.Count;
|
|
}
|
|
|
|
/// <summary>Legacy display line: optional world date · beat prose.</summary>
|
|
public static string FormatLegacyLine(LifeSagaFact fact) => SagaProse.FormatLegacyLine(fact);
|
|
|
|
public static string FormatFamilySummary(int childCount)
|
|
{
|
|
if (childCount <= 0)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
if (childCount == 1)
|
|
{
|
|
return "Had a child";
|
|
}
|
|
|
|
return "Had " + childCount + " children";
|
|
}
|
|
|
|
public static bool IsParentPerspective(LifeSagaFact fact)
|
|
{
|
|
if (fact == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string key = fact.Key ?? "";
|
|
if (key.EndsWith(":mirror", StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (key.StartsWith("parent:" + fact.SubjectId + ":", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Harness / non-keyed parent records default to parent voice for the subject life.
|
|
return !key.Contains(":mirror");
|
|
}
|
|
|
|
public static List<LifeSagaChapter> ChaptersFor(long unitId, int max = 3)
|
|
{
|
|
var chapters = new List<LifeSagaChapter>(max);
|
|
List<LifeSagaFact> legacy = SelectLegacy(unitId, max);
|
|
for (int i = 0; i < legacy.Count; i++)
|
|
{
|
|
LifeSagaFact f = legacy[i];
|
|
chapters.Add(new LifeSagaChapter
|
|
{
|
|
Kind = ToChapterKind(f.Kind),
|
|
Line = FormatLegacyLine(f),
|
|
At = f.At
|
|
});
|
|
}
|
|
|
|
return chapters;
|
|
}
|
|
|
|
public static string FormatFactLine(LifeSagaFact fact) => SagaProse.FormatFactLine(fact);
|
|
|
|
private static void TryMarkKinKillRival(Actor killer, Actor victim)
|
|
{
|
|
if (killer == null || victim == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long killerId = EventFeedUtil.SafeId(killer);
|
|
// If victim is close kin of someone on roster/memory, or killer killed kin of a remembered bond.
|
|
LifeSagaLifeMemory killerLife = Get(killerId);
|
|
if (killerLife == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long victimId = EventFeedUtil.SafeId(victim);
|
|
for (int i = 0; i < killerLife.Facts.Count; i++)
|
|
{
|
|
LifeSagaFact f = killerLife.Facts[i];
|
|
if (f == null || f.OtherId == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (f.Kind == LifeSagaFactKind.BondFormed
|
|
|| f.Kind == LifeSagaFactKind.FriendFormed
|
|
|| f.Kind == LifeSagaFactKind.ParentChild)
|
|
{
|
|
// Victim killed someone close to killer? Already handled as Kill.
|
|
// Killer slew kin of a bonded other: check victim parents/children against bonded id.
|
|
if (IsCloseKin(victim, f.OtherId))
|
|
{
|
|
Actor rival = EventFeedUtil.FindAliveById(f.OtherId);
|
|
if (rival != null)
|
|
{
|
|
EarnRival(killer, rival, "killed_kin", "kin_kill");
|
|
EarnRival(rival, killer, "kin_slain", "kin_kill");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_ = victimId;
|
|
}
|
|
|
|
private static bool IsCloseKin(Actor actor, long relativeId)
|
|
{
|
|
if (actor == null || relativeId == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
foreach (Actor p in ActorRelation.EnumerateParents(actor, 2))
|
|
{
|
|
if (EventFeedUtil.SafeId(p) == relativeId)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
foreach (Actor c in ActorRelation.EnumerateChildren(actor, 8))
|
|
{
|
|
if (EventFeedUtil.SafeId(c) == relativeId)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
Actor lover = ActorRelation.GetLover(actor);
|
|
if (lover != null && EventFeedUtil.SafeId(lover) == relativeId)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static void MirrorToOther(LifeSagaFact fact, long otherId)
|
|
{
|
|
if (fact == null || otherId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
LifeSagaLifeMemory life = GetOrCreate(otherId);
|
|
if (life == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string mirrorKey = fact.Key + ":mirror";
|
|
if (FindByKey(life, mirrorKey) != null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
LifeSagaFactKind mirrorKind = fact.Kind;
|
|
if (fact.Kind == LifeSagaFactKind.Kill)
|
|
{
|
|
mirrorKind = LifeSagaFactKind.Death;
|
|
}
|
|
else if (fact.Kind == LifeSagaFactKind.Death)
|
|
{
|
|
mirrorKind = LifeSagaFactKind.Kill;
|
|
}
|
|
|
|
var mirror = new LifeSagaFact
|
|
{
|
|
Key = mirrorKey,
|
|
Kind = mirrorKind,
|
|
SubjectId = otherId,
|
|
OtherId = fact.SubjectId,
|
|
Subject = fact.Other.Id != 0 ? fact.Other : SnapshotId(otherId),
|
|
Other = fact.Subject,
|
|
At = fact.At,
|
|
WorldTime = fact.WorldTime,
|
|
DateLabel = fact.DateLabel,
|
|
Strength = fact.Strength,
|
|
Provenance = fact.Provenance,
|
|
KingdomKey = fact.KingdomKey,
|
|
CityKey = fact.CityKey,
|
|
ClanKey = fact.ClanKey,
|
|
FamilyKey = fact.FamilyKey,
|
|
WarKey = fact.WarKey,
|
|
PlotKey = fact.PlotKey,
|
|
Resolved = fact.Resolved,
|
|
Note = fact.Note
|
|
};
|
|
life.Facts.Insert(0, mirror);
|
|
life.TouchedAt = fact.At;
|
|
while (life.Facts.Count > MaxFactsPerLife)
|
|
{
|
|
life.Facts.RemoveAt(life.Facts.Count - 1);
|
|
}
|
|
}
|
|
|
|
private static LifeSagaFact FindByKey(LifeSagaLifeMemory life, string key)
|
|
{
|
|
if (life == null || string.IsNullOrEmpty(key))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
for (int i = 0; i < life.Facts.Count; i++)
|
|
{
|
|
if (life.Facts[i] != null
|
|
&& string.Equals(life.Facts[i].Key, key, StringComparison.Ordinal))
|
|
{
|
|
return life.Facts[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static void PruneIfNeeded()
|
|
{
|
|
if (Lives.Count < MaxLives)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var ranked = new List<LifeSagaLifeMemory>(Lives.Count);
|
|
foreach (KeyValuePair<long, LifeSagaLifeMemory> kv in Lives)
|
|
{
|
|
ranked.Add(kv.Value);
|
|
}
|
|
|
|
ranked.Sort((a, b) =>
|
|
{
|
|
int ap = ProtectScore(a);
|
|
int bp = ProtectScore(b);
|
|
int cmp = ap.CompareTo(bp);
|
|
if (cmp != 0)
|
|
{
|
|
return cmp;
|
|
}
|
|
|
|
return a.TouchedAt.CompareTo(b.TouchedAt);
|
|
});
|
|
|
|
int drop = Mathf.Max(1, Lives.Count - MaxLives + 8);
|
|
for (int i = 0; i < ranked.Count && drop > 0; i++)
|
|
{
|
|
LifeSagaLifeMemory life = ranked[i];
|
|
if (ProtectScore(life) >= 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Lives.Remove(life.UnitId);
|
|
drop--;
|
|
}
|
|
}
|
|
|
|
private static int ProtectScore(LifeSagaLifeMemory life)
|
|
{
|
|
if (life == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (LifeSagaRoster.IsMc(life.UnitId))
|
|
{
|
|
return 3;
|
|
}
|
|
|
|
LifeSagaSlot slot = LifeSagaRoster.Get(life.UnitId);
|
|
if (slot != null && (slot.Prefer || slot.GameFavorite))
|
|
{
|
|
return 3;
|
|
}
|
|
|
|
for (int i = 0; i < life.Facts.Count; i++)
|
|
{
|
|
if (life.Facts[i] != null && !life.Facts[i].Resolved && IsStakeWorthy(life.Facts[i]))
|
|
{
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
private static string DefaultKey(
|
|
LifeSagaFactKind kind,
|
|
long subjectId,
|
|
long otherId,
|
|
string warKey,
|
|
string plotKey)
|
|
{
|
|
return kind + ":" + subjectId + ":" + otherId + ":" + (warKey ?? "") + ":" + (plotKey ?? "");
|
|
}
|
|
|
|
private static string PairKey(long a, long b)
|
|
{
|
|
return a <= b ? a + ":" + b : b + ":" + a;
|
|
}
|
|
|
|
private static bool IsStakeWorthy(LifeSagaFact fact)
|
|
{
|
|
if (fact == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (fact.McCrossover)
|
|
{
|
|
return IsMcCrossoverKind(fact.Kind) || fact.Kind == LifeSagaFactKind.RivalEarned;
|
|
}
|
|
|
|
return IsStakeWorthyKind(fact.Kind);
|
|
}
|
|
|
|
private static bool IsStakeWorthyKind(LifeSagaFactKind kind)
|
|
{
|
|
switch (kind)
|
|
{
|
|
case LifeSagaFactKind.WarJoin:
|
|
case LifeSagaFactKind.PlotJoin:
|
|
case LifeSagaFactKind.RivalEarned:
|
|
case LifeSagaFactKind.BondBroken:
|
|
case LifeSagaFactKind.HardArcWar:
|
|
case LifeSagaFactKind.HardArcPlot:
|
|
case LifeSagaFactKind.HardArcGrief:
|
|
case LifeSagaFactKind.RoleChange:
|
|
case LifeSagaFactKind.Founding:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Weaker unresolved facts used only when no primary stake exists.
|
|
/// Kind-gated: HardArcCombat, open BondFormed, and McCrossover Kill (Kill already via McCrossover path).
|
|
/// </summary>
|
|
private static bool IsStakeFallbackWorthy(LifeSagaFact fact)
|
|
{
|
|
if (fact == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (fact.McCrossover && fact.Kind == LifeSagaFactKind.Kill)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
switch (fact.Kind)
|
|
{
|
|
case LifeSagaFactKind.HardArcCombat:
|
|
case LifeSagaFactKind.BondFormed:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static void TryMarkMcCrossover(LifeSagaFact fact)
|
|
{
|
|
if (fact == null || fact.OtherId == 0 || fact.SubjectId == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!IsMcCrossoverKind(fact.Kind))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!LifeSagaRoster.IsMc(fact.SubjectId) || !LifeSagaRoster.IsMc(fact.OtherId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
fact.McCrossover = true;
|
|
NoteMcCrossoverHeat(fact.SubjectId, fact.OtherId);
|
|
}
|
|
|
|
private static bool IsMcCrossoverKind(LifeSagaFactKind kind)
|
|
{
|
|
switch (kind)
|
|
{
|
|
case LifeSagaFactKind.Kill:
|
|
case LifeSagaFactKind.Death:
|
|
case LifeSagaFactKind.BondFormed:
|
|
case LifeSagaFactKind.BondBroken:
|
|
case LifeSagaFactKind.FriendFormed:
|
|
case LifeSagaFactKind.ParentChild:
|
|
case LifeSagaFactKind.RivalEarned:
|
|
case LifeSagaFactKind.HardArcLove:
|
|
case LifeSagaFactKind.HardArcGrief:
|
|
case LifeSagaFactKind.HardArcCombat:
|
|
case LifeSagaFactKind.HardArcWar:
|
|
case LifeSagaFactKind.HardArcPlot:
|
|
case LifeSagaFactKind.WarJoin:
|
|
case LifeSagaFactKind.PlotJoin:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static void NoteMcCrossoverHeat(long a, long b)
|
|
{
|
|
if (a != 0)
|
|
{
|
|
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.HardArcLove:
|
|
return 1;
|
|
case LifeSagaFactKind.RoleChange:
|
|
return 2;
|
|
case LifeSagaFactKind.Kill:
|
|
case LifeSagaFactKind.CombatEncounter:
|
|
case LifeSagaFactKind.RivalEarned:
|
|
case LifeSagaFactKind.HardArcCombat:
|
|
case LifeSagaFactKind.WarJoin:
|
|
case LifeSagaFactKind.WarEnd:
|
|
case LifeSagaFactKind.HardArcWar:
|
|
case LifeSagaFactKind.PlotJoin:
|
|
case LifeSagaFactKind.PlotEnd:
|
|
case LifeSagaFactKind.HardArcPlot:
|
|
return 3;
|
|
case LifeSagaFactKind.BondBroken:
|
|
case LifeSagaFactKind.HomeLost:
|
|
case LifeSagaFactKind.Death:
|
|
case LifeSagaFactKind.HardArcGrief:
|
|
return 4;
|
|
case LifeSagaFactKind.ParentChild:
|
|
// Own bucket so births do not block Bond on the diversity pass,
|
|
// and so pass-1 fill cannot turn Legacy into child soup.
|
|
return 5;
|
|
default:
|
|
return 6;
|
|
}
|
|
}
|
|
|
|
private static bool ContainsFact(List<LifeSagaFact> list, LifeSagaFact fact)
|
|
{
|
|
for (int i = 0; i < list.Count; i++)
|
|
{
|
|
if (list[i] != null && list[i].Key == fact.Key)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static 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";
|
|
}
|
|
|
|
if (roleId.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| roleId.IndexOf("clan", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return "Became clan chief";
|
|
}
|
|
|
|
if (roleId.IndexOf("captain", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| roleId.IndexOf("army", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return "Became army captain";
|
|
}
|
|
|
|
return "Role: " + roleId;
|
|
}
|
|
|
|
private static string FirstNonEmpty(params string[] values)
|
|
{
|
|
if (values == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
for (int i = 0; i < values.Length; i++)
|
|
{
|
|
if (!string.IsNullOrEmpty(values[i]))
|
|
{
|
|
return values[i];
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
private static void StampWorldDate(out double worldTime, out string dateLabel)
|
|
{
|
|
worldTime = 0;
|
|
dateLabel = "";
|
|
try
|
|
{
|
|
if (World.world == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
worldTime = World.world.getCurWorldTime();
|
|
int[] raw = Date.getRawDate(worldTime);
|
|
if (raw != null && raw.Length >= 3)
|
|
{
|
|
string monthName = Date.formatMonth(raw[1]);
|
|
if (string.IsNullOrEmpty(monthName))
|
|
{
|
|
monthName = "m" + raw[1];
|
|
}
|
|
|
|
dateLabel = monthName + " " + raw[2];
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// keep empty
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum LifeSagaFactKind
|
|
{
|
|
Unknown,
|
|
BondFormed,
|
|
BondBroken,
|
|
FriendFormed,
|
|
ParentChild,
|
|
Kill,
|
|
Death,
|
|
CombatEncounter,
|
|
RivalEarned,
|
|
WarJoin,
|
|
WarEnd,
|
|
PlotJoin,
|
|
PlotEnd,
|
|
Founding,
|
|
RoleChange,
|
|
HomeGained,
|
|
HomeLost,
|
|
HardArcLove,
|
|
HardArcGrief,
|
|
HardArcCombat,
|
|
HardArcWar,
|
|
HardArcPlot
|
|
}
|
|
|
|
public struct LifeSagaIdentity
|
|
{
|
|
public long Id;
|
|
public string Name;
|
|
public string SpeciesId;
|
|
}
|
|
|
|
public sealed class LifeSagaFact
|
|
{
|
|
public string Key = "";
|
|
public LifeSagaFactKind Kind;
|
|
public long SubjectId;
|
|
public long OtherId;
|
|
public LifeSagaIdentity Subject;
|
|
public LifeSagaIdentity Other;
|
|
public float At;
|
|
public double WorldTime;
|
|
public string DateLabel = "";
|
|
public float Strength;
|
|
public string Provenance = "";
|
|
public string KingdomKey = "";
|
|
public string CityKey = "";
|
|
public string ClanKey = "";
|
|
public string FamilyKey = "";
|
|
public string WarKey = "";
|
|
public string PlotKey = "";
|
|
public bool Resolved;
|
|
public string Note = "";
|
|
/// <summary>Both subject and other were roster MCs when recorded.</summary>
|
|
public bool McCrossover;
|
|
}
|
|
|
|
public sealed class LifeSagaLifeMemory
|
|
{
|
|
public long UnitId;
|
|
public LifeSagaIdentity Identity;
|
|
public float TouchedAt;
|
|
public readonly List<LifeSagaFact> Facts = new List<LifeSagaFact>(16);
|
|
}
|