using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace IdleSpectator;
///
/// Durable dynamic five-character life-saga cast. Owns rail Prefer, diversity, and soft camera bias.
/// Independent of short-arc Clear.
///
public static class LifeSagaRoster
{
public const int Cap = 5;
public const int MaxChapters = 3;
public const float ChallengerMargin = 1.5f;
public const float HeatCap = 12f;
private static readonly List Slots = new List(Cap);
private static readonly List Scratch = new List(48);
private static readonly List ChallengerScratch = new List(64);
private static readonly List ChallengerPriorityScratch = new List(64);
private static readonly List UnitScanSnapshot = new List(512);
private static readonly HashSet ScanSeen = new HashSet();
private static readonly HashSet PreviousIds = new HashSet();
private static readonly HashSet PlotAuthorCache = new HashSet();
private static readonly Dictionary SpeciesCountCache = new Dictionary();
private static readonly HashSet McOrbitCache = new HashSet();
private static readonly List TouchScratch = new List(8);
private static readonly List NarrativeChallengerScratch =
new List(8);
private static float _nextScanAt;
private static float _nextNarrativeAdmissionAt;
private static float _roleCacheAt = -999f;
private static bool _dirty = true;
private static WorldScanPhase _scanPhase = WorldScanPhase.None;
private static int _scanIndex;
private static int _scanLimit;
private static int _scanCommitIndex;
private static List _liveScanSource;
private static float _scanStartedAt;
private static int _scanPinCount;
private enum WorldScanPhase
{
None,
Walking,
Commit
}
/// Units probed per frame during amortized world admission.
private const int WorldScanUnitsPerFrame = 12;
/// Full challenger dossiers built per frame after a world scan.
private const int WorldScanBuildsPerFrame = 2;
private const int MaxScanAdmits = Cap * 2;
private const int RareSpeciesMaxPopulation = 5;
private const float SpecialCreatureStandingBonus = 2f;
private const float RareCreatureStandingBonus = 3f;
private const float RecurringAntagonistStandingBonus = 7f;
private const float EstablishedMcSeconds = 120f;
private const float EstablishedMcMarginBonus = 3f;
private const float UndevelopedChallengerMarginBonus = 2f;
/// Cadence for starting a spread world admission scan.
private const float WorldScanIntervalSeconds = 3.5f;
/// Last timed world-scan refill cost in ms (hitch probe).
public static float LastWorldScanMs { get; private set; }
/// Last timed soft refill cost in ms (hitch probe).
public static float LastSoftRefillMs { get; private set; }
public static string LastAdmissionDiagnostic { get; private set; } = "";
public static int Count => Slots.Count;
public static bool Dirty
{
get => _dirty;
set => _dirty = value;
}
public static void Clear()
{
for (int i = 0; i < Slots.Count; i++)
{
Slots[i]?.Chapters.Clear();
}
Slots.Clear();
Scratch.Clear();
ChallengerScratch.Clear();
ChallengerPriorityScratch.Clear();
UnitScanSnapshot.Clear();
ScanSeen.Clear();
PreviousIds.Clear();
PlotAuthorCache.Clear();
SpeciesCountCache.Clear();
McOrbitCache.Clear();
NarrativeChallengerScratch.Clear();
ActorRelation.ClearObservedRelations();
_nextScanAt = 0f;
_nextNarrativeAdmissionAt = 0f;
_roleCacheAt = -999f;
_scanPhase = WorldScanPhase.None;
_scanIndex = 0;
_scanLimit = 0;
_scanCommitIndex = 0;
_liveScanSource = null;
LastAdmissionDiagnostic = "";
_dirty = true;
}
public static void CopySlots(List into)
{
if (into == null)
{
return;
}
into.Clear();
for (int i = 0; i < Slots.Count; i++)
{
if (Slots[i] != null && Slots[i].UnitId != 0)
{
into.Add(Slots[i]);
}
}
}
public static LifeSagaSlot Get(long unitId)
{
if (unitId == 0)
{
return null;
}
for (int i = 0; i < Slots.Count; i++)
{
if (Slots[i] != null && Slots[i].UnitId == unitId)
{
return Slots[i];
}
}
return null;
}
public static bool IsMc(long unitId) => Get(unitId) != null;
public static bool IsPrefer(long unitId)
{
LifeSagaSlot s = Get(unitId);
return s != null && s.Prefer;
}
public static bool IsPin(LifeSagaSlot s) =>
s != null && (s.Prefer || s.GameFavorite);
public static bool TogglePrefer(long unitId)
{
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return false;
}
s.Prefer = !s.Prefer;
NarrativePersistence.NotePreference(s);
_dirty = true;
return true;
}
public static bool SetPrefer(long unitId, bool prefer)
{
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return false;
}
if (s.Prefer == prefer)
{
return true;
}
s.Prefer = prefer;
NarrativePersistence.NotePreference(s);
_dirty = true;
return true;
}
/// Harness: force a living unit onto the roster (bypasses admission for tests).
public static bool HarnessForceAdmit(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return false;
}
long id = EventFeedUtil.SafeId(actor);
if (id == 0)
{
return false;
}
float now = Time.unscaledTime;
EnsureRoleCache(now, force: true);
LifeSagaSlot existing = Get(id);
if (existing != null)
{
RefreshSlot(existing, actor, now);
existing.Heat = Mathf.Max(existing.Heat, 4f);
existing.TouchedAt = now;
_dirty = true;
RebuildMcOrbitCache();
return true;
}
LifeSagaSlot neu = BuildSlot(actor, now);
neu.Heat = Mathf.Max(neu.Heat, 4f);
if (Slots.Count >= Cap)
{
int worst = FindWeakestNonPinIndex(now);
if (worst >= 0)
{
Slots.RemoveAt(worst);
}
else if (Slots.Count > 0)
{
// All pins - still allow harness to force by dropping last non-pin-less slot.
Slots.RemoveAt(Slots.Count - 1);
}
}
Slots.Add(neu);
_dirty = true;
RebuildMcOrbitCache();
return true;
}
///
/// Harness: run a one-shot world admission scan while Busy normally suppresses discovery.
///
public static void HarnessWorldScan()
{
float now = Time.unscaledTime;
EnsureRoleCache(now, force: true);
Scratch.Clear();
PreviousIds.Clear();
HashSet seen = new HashSet();
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot s = Slots[i];
if (s == null || s.UnitId == 0)
{
continue;
}
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
if (a == null || !a.isAlive())
{
continue;
}
RefreshSlot(s, a, now);
Scratch.Add(s);
seen.Add(s.UnitId);
PreviousIds.Add(s.UnitId);
}
try
{
if (World.world?.units != null)
{
foreach (Actor actor in World.world.units)
{
if (actor == null || !actor.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(actor);
if (id == 0 || seen.Contains(id))
{
continue;
}
if (!IsAdmissionCandidate(actor))
{
continue;
}
Scratch.Add(BuildSlot(actor, now));
seen.Add(id);
}
}
}
catch
{
// ignore
}
RankAndCap(Scratch, now, PreviousIds);
Slots.Clear();
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
{
Slots.Add(Scratch[i]);
}
_dirty = true;
_nextScanAt = now + 2.5f;
}
///
/// Harness helper: set Standing/Heat for a roster unit so replace tests can age them out.
///
public static bool HarnessSetScores(long unitId, float standing, float heat)
{
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return false;
}
s.Standing = standing;
s.Heat = Mathf.Clamp(heat, 0f, HeatCap);
s.TouchedAt = Time.unscaledTime - 1f;
s.AdmittedAt = Time.unscaledTime - 1f;
_dirty = true;
return true;
}
public static float ScoreBonus(InterestCandidate c)
{
if (c == null)
{
return 0f;
}
StoryWeights sw = InterestScoringConfig.Story;
float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 12f;
float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 16f;
float fav = sw.sagaGameFavoriteWeight > 0f ? sw.sagaGameFavoriteWeight : 8f;
float cast = sw.sagaCastWeight > 0f ? sw.sagaCastWeight : 5f;
float cross = sw.sagaCrossMcBonus > 0f ? sw.sagaCrossMcBonus : 11f;
float best = BonusForUnit(c.SubjectId, mc, prefer, fav, cast);
if (c.RelatedId != 0)
{
best = Mathf.Max(best, BonusForUnit(c.RelatedId, mc, prefer, fav, cast) * 0.85f);
}
long followId = EventFeedUtil.SafeId(c.FollowUnit);
if (followId != 0 && followId != c.SubjectId && followId != c.RelatedId)
{
best = Mathf.Max(best, BonusForUnit(followId, mc, prefer, fav, cast));
}
if (CountTouchedMcs(c) >= 2)
{
best += cross;
}
float cap = Mathf.Max(prefer, fav) + mc + cross;
return Mathf.Min(best, cap);
}
private static float BonusForUnit(long unitId, float mc, float prefer, float fav, float cast)
{
if (unitId == 0)
{
return 0f;
}
LifeSagaSlot s = Get(unitId);
if (s != null)
{
float bonus = mc;
if (s.Prefer)
{
bonus += prefer;
}
if (s.GameFavorite)
{
bonus += fav;
}
return bonus;
}
return IsMcCast(unitId) ? cast : 0f;
}
/// Among candidates, pick Prefer'd MC, else any MC, else null.
public static Actor PreferRosterUnit(IEnumerable candidates)
{
if (candidates == null)
{
return null;
}
float now = Time.unscaledTime;
float dull = DullSeconds();
Actor bestPrefer = null;
Actor bestMc = null;
float bestPreferScore = float.MinValue;
float bestMcScore = float.MinValue;
foreach (Actor a in candidates)
{
if (a == null || !a.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(a);
LifeSagaSlot s = Get(id);
if (s == null)
{
continue;
}
float score = EffectiveInterest(s, now, dull);
if (s.Prefer && score >= bestPreferScore)
{
bestPreferScore = score;
bestPrefer = a;
}
if (score >= bestMcScore)
{
bestMcScore = score;
bestMc = a;
}
}
return bestPrefer ?? bestMc;
}
///
/// Best living current MC for quiet camera grounding. Bounded by the five-slot roster;
/// unlike the world activity scanner this never walks the population.
///
public static Actor BestLivingMc()
{
float now = Time.unscaledTime;
float dull = DullSeconds();
Actor best = null;
float bestScore = float.MinValue;
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
if (slot == null || slot.UnitId == 0)
{
continue;
}
Actor actor = EventFeedUtil.FindAliveById(slot.UnitId);
if (actor == null || !actor.isAlive())
{
continue;
}
float score = EffectiveInterest(slot, now, dull)
+ (slot.Prefer ? 20f : 0f)
+ (slot.GameFavorite ? 8f : 0f);
if (best == null || score > bestScore)
{
best = actor;
bestScore = score;
}
}
return best;
}
public static bool TipTouchesMc(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (IsMc(c.SubjectId) || IsMc(c.RelatedId))
{
return true;
}
return IsMc(EventFeedUtil.SafeId(c.FollowUnit));
}
public static bool TipTouchesPrefer(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (IsPrefer(c.SubjectId) || IsPrefer(c.RelatedId))
{
return true;
}
return IsPrefer(EventFeedUtil.SafeId(c.FollowUnit));
}
public static bool TipTouchesMcCast(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (IsMcCast(c.SubjectId) || IsMcCast(c.RelatedId))
{
return true;
}
return IsMcCast(EventFeedUtil.SafeId(c.FollowUnit));
}
/// Prefer=4, CrossMC=3, MC=2, MC cast=1, else 0.
public static int SoftBiasRank(InterestCandidate c)
{
if (c == null)
{
return 0;
}
if (TipTouchesPrefer(c))
{
return 4;
}
if (CountTouchedMcs(c) >= 2)
{
return 3;
}
if (TipTouchesMc(c))
{
return 2;
}
return TipTouchesMcCast(c) ? 1 : 0;
}
/// Near-tie breaker: Prefer > CrossMC > MC > MC cast > neither.
public static int CompareSoftBias(InterestCandidate a, InterestCandidate b)
{
return SoftBiasRank(b).CompareTo(SoftBiasRank(a));
}
/// True when unit is in a roster MC's live story orbit (not themselves an MC).
public static bool IsMcCast(long unitId)
{
if (unitId == 0 || IsMc(unitId))
{
return false;
}
return McOrbitCache.Contains(unitId);
}
///
/// Roster MCs touched by the tip for rail involved chrome.
/// Bounded to subject/related/follow/pair/short-arc cast - not mass ParticipantIds.
///
public static void CollectTouchedMcIds(InterestCandidate tip, List dest)
{
if (dest == null)
{
return;
}
dest.Clear();
if (tip == null)
{
return;
}
void Add(long id)
{
if (id == 0 || !IsMc(id))
{
return;
}
for (int i = 0; i < dest.Count; i++)
{
if (dest[i] == id)
{
return;
}
}
dest.Add(id);
}
Add(tip.SubjectId);
Add(tip.RelatedId);
Add(EventFeedUtil.SafeId(tip.FollowUnit));
Add(tip.PairOwnerId);
Add(tip.PairPartnerId);
Add(tip.TheaterLeadId);
StoryArc arc = StoryPlanner.Active;
if (arc?.CastIds != null)
{
for (int i = 0; i < arc.CastIds.Count; i++)
{
Add(arc.CastIds[i]);
}
}
}
public static int CountTouchedMcs(InterestCandidate tip)
{
CollectTouchedMcIds(tip, TouchScratch);
return TouchScratch.Count;
}
///
/// Heat roster MCs early while a sticky combat/war/plot tip still holds two living principals.
/// Helps Pick land on cross-MC moments before fact recording.
///
public static void NoteFeaturedCrossMcPrincipals(InterestCandidate tip, float heat = 0.85f)
{
if (tip == null || heat <= 0f)
{
return;
}
CollectTouchedMcIds(tip, TouchScratch);
if (TouchScratch.Count < 2)
{
return;
}
for (int i = 0; i < TouchScratch.Count; i++)
{
NoteFeaturedUnit(TouchScratch[i], heat);
}
}
private static bool IsInMcOrbit(long mcId, long otherId)
{
if (mcId == 0 || otherId == 0 || mcId == otherId)
{
return false;
}
Actor mc = EventFeedUtil.FindAliveById(mcId);
if (mc != null)
{
Actor lover = ActorRelation.GetLover(mc);
if (lover != null && EventFeedUtil.SafeId(lover) == otherId)
{
return true;
}
Actor friend = ActorRelation.GetBestFriend(mc);
if (friend != null && EventFeedUtil.SafeId(friend) == otherId)
{
return true;
}
try
{
foreach (Actor parent in ActorRelation.EnumerateParents(mc, 2))
{
if (EventFeedUtil.SafeId(parent) == otherId)
{
return true;
}
}
foreach (Actor child in ActorRelation.EnumerateChildren(mc, 4))
{
if (EventFeedUtil.SafeId(child) == otherId)
{
return true;
}
}
}
catch
{
// ignore relation probe failures
}
}
if (LifeSagaMemory.TryGetEarnedRival(mcId, out LifeSagaFact rival)
&& rival != null
&& rival.OtherId == otherId)
{
return true;
}
return false;
}
public static void StampChapter(long unitId, string line, float now = -1f)
{
StampChapter(unitId, LifeSagaChapterKind.Unknown, line, now);
}
public static void StampChapter(
long unitId,
LifeSagaChapterKind kind,
string line,
float now = -1f)
{
if (unitId == 0 || string.IsNullOrEmpty(line))
{
return;
}
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return;
}
if (now < 0f)
{
now = Time.unscaledTime;
}
string trimmed = CleanChapterLine(line);
if (string.IsNullOrEmpty(trimmed))
{
return;
}
// Never stamp the unit's own name as a chapter beat.
if (!string.IsNullOrEmpty(s.DisplayName)
&& string.Equals(trimmed, s.DisplayName.Trim(), StringComparison.OrdinalIgnoreCase))
{
return;
}
if (s.Chapters.Count > 0
&& string.Equals(s.Chapters[0]?.Line, trimmed, StringComparison.Ordinal))
{
s.Chapters[0].Kind = kind;
s.Chapters[0].At = now;
s.LastChapterAt = now;
s.TouchedAt = now;
return;
}
s.Chapters.Insert(0, new LifeSagaChapter
{
Kind = kind,
Line = trimmed,
At = now
});
while (s.Chapters.Count > MaxChapters)
{
s.Chapters.RemoveAt(s.Chapters.Count - 1);
}
s.LastChapterAt = now;
s.TouchedAt = now;
s.Heat = Mathf.Min(HeatCap, s.Heat + 1.5f);
_dirty = true;
}
public static void Tick(float now)
{
if (now >= _nextNarrativeAdmissionAt)
{
_nextNarrativeAdmissionAt = now + 1f;
ConsiderEmergingStories(now);
}
if (_scanPhase != WorldScanPhase.None)
{
ContinueAmortizedWorldScan(now);
return;
}
if (now < _nextScanAt)
{
if (_dirty)
{
// Membership / Prefer / heat-driven dirty: re-rank existing slots only.
// Full world admission stays on the timed cadence to avoid hitch spikes.
Refill(now, worldScan: false);
}
else
{
PruneDead(now);
}
return;
}
_nextScanAt = now + WorldScanIntervalSeconds;
if (AgentHarness.Busy)
{
Refill(now, worldScan: false);
return;
}
BeginAmortizedWorldScan(now);
}
///
/// Spread admission discovery across frames so a 2k-unit map cannot hitch on one Tick.
///
private static void BeginAmortizedWorldScan(float now)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
EnsureRoleCache(now, force: false);
Scratch.Clear();
PreviousIds.Clear();
ScanSeen.Clear();
ChallengerScratch.Clear();
ChallengerPriorityScratch.Clear();
UnitScanSnapshot.Clear();
_scanPinCount = 0;
_scanCommitIndex = 0;
_scanStartedAt = now;
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot s = Slots[i];
if (s == null || s.UnitId == 0)
{
continue;
}
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
if (a == null || !a.isAlive())
{
continue;
}
RefreshSlot(s, a, now);
Scratch.Add(s);
ScanSeen.Add(s.UnitId);
PreviousIds.Add(s.UnitId);
if (IsPin(s))
{
_scanPinCount++;
}
}
if (_scanPinCount >= Cap)
{
FinishRefillFromScratch(now, worldScan: true, sw);
return;
}
try
{
// The game already maintains an indexed living-unit list. Keep a reference and
// walk a bounded slice per frame instead of copying the entire population up
// front; that copy was itself a recurring main-thread hitch on mature worlds.
List alive = World.world?.units?.units_only_alive;
if (alive != null && alive.Count > 0)
{
_liveScanSource = alive;
_scanLimit = alive.Count;
}
else if (World.world?.units != null)
{
foreach (Actor actor in World.world.units)
{
if (actor != null && actor.isAlive())
{
UnitScanSnapshot.Add(actor);
}
}
_scanLimit = UnitScanSnapshot.Count;
}
}
catch
{
// ignore
}
_scanIndex = 0;
_scanPhase = WorldScanPhase.Walking;
sw.Stop();
LastWorldScanMs = (float)sw.Elapsed.TotalMilliseconds;
}
private static void ContinueAmortizedWorldScan(float now)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
if (_scanPhase == WorldScanPhase.Walking)
{
int available = _liveScanSource != null
? Mathf.Min(_scanLimit, _liveScanSource.Count)
: UnitScanSnapshot.Count;
int end = Mathf.Min(_scanIndex + WorldScanUnitsPerFrame, available);
for (int i = _scanIndex; i < end; i++)
{
Actor actor = _liveScanSource != null
? _liveScanSource[i]
: UnitScanSnapshot[i];
if (actor == null || !actor.isAlive())
{
continue;
}
ActorRelation.ObserveParentLinks(actor);
long id = EventFeedUtil.SafeId(actor);
if (id == 0 || ScanSeen.Contains(id))
{
continue;
}
if (!IsAdmissionCandidate(actor))
{
continue;
}
ConsiderWorldScanChallenger(actor);
ScanSeen.Add(id);
}
_scanIndex = end;
if (_scanIndex < available)
{
sw.Stop();
LastWorldScanMs = Mathf.Max(LastWorldScanMs, (float)sw.Elapsed.TotalMilliseconds);
return;
}
_scanPhase = WorldScanPhase.Commit;
}
if (_scanPhase == WorldScanPhase.Commit)
{
int end = Mathf.Min(
_scanCommitIndex + WorldScanBuildsPerFrame,
ChallengerScratch.Count);
for (int i = _scanCommitIndex; i < end; i++)
{
Actor actor = ChallengerScratch[i];
long id = EventFeedUtil.SafeId(actor);
if (id == 0)
{
continue;
}
Scratch.Add(BuildSlot(actor, now));
}
_scanCommitIndex = end;
if (_scanCommitIndex < ChallengerScratch.Count)
{
sw.Stop();
LastWorldScanMs = Mathf.Max(
LastWorldScanMs,
(float)sw.Elapsed.TotalMilliseconds);
return;
}
FinishRefillFromScratch(now, worldScan: true, sw);
ChallengerScratch.Clear();
ChallengerPriorityScratch.Clear();
UnitScanSnapshot.Clear();
ScanSeen.Clear();
_liveScanSource = null;
_scanLimit = 0;
_scanPhase = WorldScanPhase.None;
_scanIndex = 0;
_scanCommitIndex = 0;
_nextScanAt = now + WorldScanIntervalSeconds;
return;
}
sw.Stop();
}
private static void FinishRefillFromScratch(
float now,
bool worldScan,
System.Diagnostics.Stopwatch sw)
{
RankAndCap(Scratch, now, PreviousIds);
Slots.Clear();
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
{
Slots.Add(Scratch[i]);
}
_dirty = false;
RebuildMcOrbitCache();
sw.Stop();
float ms = (float)sw.Elapsed.TotalMilliseconds;
if (worldScan)
{
LastWorldScanMs = Mathf.Max(LastWorldScanMs, ms);
}
else
{
LastSoftRefillMs = ms;
}
}
///
/// Soft featured tips heat existing MCs only - never admit nobodies.
///
public static void NoteFeatured(InterestCandidate scene)
{
if (scene == null)
{
return;
}
float now = Time.unscaledTime;
NoteUnit(scene.SubjectId, 0.8f, now);
if (scene.RelatedId != 0)
{
NoteUnit(scene.RelatedId, 0.5f, now);
}
long follow = EventFeedUtil.SafeId(scene.FollowUnit);
if (follow != 0)
{
NoteUnit(follow, 1f, now);
}
}
/// Soft heat bump for an existing roster MC (cross-MC moments).
public static void NoteFeaturedUnit(long unitId, float heat = 1f)
{
if (unitId == 0)
{
return;
}
NoteUnit(unitId, heat, Time.unscaledTime);
}
///
/// Challenge Cap with a living unit. Admission roles always may compete;
/// ordinary characters only when coherent story momentum qualifies them.
///
public static bool ConsiderAdmit(
long unitId,
float heat,
float now = -1f,
bool emergingStoryAdmit = false)
{
if (unitId == 0)
{
return false;
}
if (now < 0f)
{
now = Time.unscaledTime;
}
Actor actor = EventFeedUtil.FindAliveById(unitId);
if (actor == null || !actor.isAlive())
{
return false;
}
LifeSagaSlot existing = Get(unitId);
if (existing != null)
{
// Heat-only: do not RefreshSlot or force a world Refill (idle hot path).
existing.Heat = Mathf.Min(HeatCap, existing.Heat + Mathf.Max(0f, heat));
existing.TouchedAt = now;
return true;
}
EnsureRoleCache(now, force: false);
bool admission = IsAdmissionCandidate(actor);
if (!admission && !emergingStoryAdmit)
{
LastAdmissionDiagnostic =
"id=" + unitId + " result=rejected reason=ineligible asset="
+ (actor.asset?.id ?? "");
return false;
}
LifeSagaSlot neu = BuildSlot(
actor,
now,
emergingStoryAdmit && !admission ? LifeSagaAdmissionReason.EmergingStory
: LifeSagaAdmissionReason.None);
neu.Heat = Mathf.Min(HeatCap, Mathf.Max(0f, heat));
Scratch.Clear();
PreviousIds.Clear();
for (int i = 0; i < Slots.Count; i++)
{
if (Slots[i] != null && Slots[i].UnitId != 0)
{
Scratch.Add(Slots[i]);
PreviousIds.Add(Slots[i].UnitId);
}
}
float challengerScore = EffectiveInterest(neu, now);
float weakestScore = float.PositiveInfinity;
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot incumbent = Slots[i];
if (incumbent == null || IsPin(incumbent)) continue;
weakestScore = Mathf.Min(weakestScore, EffectiveInterest(incumbent, now));
}
Scratch.Add(neu);
RankAndCap(Scratch, now, PreviousIds);
Slots.Clear();
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
{
Slots.Add(Scratch[i]);
}
_dirty = true;
bool admitted = IsMc(unitId);
LastAdmissionDiagnostic =
"id=" + unitId
+ " result=" + (admitted ? "admitted" : "rejected")
+ " reason=" + neu.AdmissionReason
+ " score=" + challengerScore.ToString("0.##")
+ " weakest=" + (float.IsPositiveInfinity(weakestScore)
? "none"
: weakestScore.ToString("0.##"))
+ " margin=" + ChallengerMargin.ToString("0.##")
+ " pop=" + neu.SpeciesPopulation
+ " rare=" + neu.IsRareCreature
+ " special=" + neu.IsSpecialCreature
+ " antagonist=" + neu.IsRecurringAntagonist
+ " anchor=" + neu.AntagonistAnchorMcId
+ " evidence=" + neu.AntagonistEvidence;
return admitted;
}
private static void ConsiderEmergingStories(float now)
{
NarrativeStoryStore.CopyEmergingCharacters(NarrativeChallengerScratch, 6f, 4);
for (int i = 0; i < NarrativeChallengerScratch.Count; i++)
{
CharacterStoryState state = NarrativeChallengerScratch[i];
if (Get(state.CharacterId) != null) continue;
float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now);
ConsiderAdmit(
state.CharacterId,
heat: Mathf.Clamp(potential * 0.5f, 1f, HeatCap),
now: now,
emergingStoryAdmit: true);
}
}
private static void NoteUnit(long unitId, float heat, float now)
{
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return;
}
// Soft heat must not mark Dirty - that forced a full world Refill every tip.
s.Heat = Mathf.Min(HeatCap, s.Heat + heat);
s.TouchedAt = now;
}
private static void Refill(float now, bool worldScan)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
// Soft / harness path: never walk the whole world on this call.
// Live AFK discovery uses BeginAmortizedWorldScan instead.
EnsureRoleCache(now, force: false);
Scratch.Clear();
PreviousIds.Clear();
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot s = Slots[i];
if (s == null || s.UnitId == 0)
{
continue;
}
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
if (a == null || !a.isAlive())
{
continue;
}
RefreshSlot(s, a, now);
Scratch.Add(s);
PreviousIds.Add(s.UnitId);
}
_ = worldScan;
FinishRefillFromScratch(now, worldScan: false, sw);
}
private static void RebuildMcOrbitCache()
{
McOrbitCache.Clear();
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
if (slot == null || slot.UnitId == 0)
{
continue;
}
Actor mc = EventFeedUtil.FindAliveById(slot.UnitId);
if (mc == null)
{
continue;
}
void Add(Actor a)
{
long id = EventFeedUtil.SafeId(a);
if (id != 0 && id != slot.UnitId)
{
McOrbitCache.Add(id);
}
}
Add(ActorRelation.GetLover(mc));
Add(ActorRelation.GetBestFriend(mc));
try
{
foreach (Actor parent in ActorRelation.EnumerateParents(mc, 2))
{
Add(parent);
}
foreach (Actor child in ActorRelation.EnumerateChildren(mc, 4))
{
Add(child);
}
}
catch
{
// ignore
}
if (LifeSagaMemory.TryGetEarnedRival(slot.UnitId, out LifeSagaFact rival)
&& rival != null
&& rival.OtherId != 0)
{
McOrbitCache.Add(rival.OtherId);
}
}
}
private static void PruneDead(float now)
{
bool changed = false;
for (int i = Slots.Count - 1; i >= 0; i--)
{
LifeSagaSlot s = Slots[i];
if (s == null)
{
Slots.RemoveAt(i);
changed = true;
continue;
}
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
if (a == null || !a.isAlive())
{
Slots.RemoveAt(i);
changed = true;
}
// Do not RefreshSlot here - that ran ~20×/frame (Director + Rail) and dominated idle CPU.
}
if (changed)
{
_dirty = true;
_nextScanAt = now;
}
}
private static void RankAndCap(List list, float now, HashSet previousIds)
{
float dull = DullSeconds();
list.Sort((a, b) => CompareSlots(a, b, now, dull));
if (list.Count <= Cap)
{
ApplyChallengerMargin(list, previousIds, now, dull);
return;
}
Dictionary kingdomCounts = new Dictionary();
Dictionary speciesCounts = new Dictionary();
List kept = new List(Cap);
// Reserve Prefer / game-favorite pins first (full Cap cost).
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
{
LifeSagaSlot s = list[i];
if (s == null || !IsPin(s) || kept.Contains(s))
{
continue;
}
kept.Add(s);
Bump(kingdomCounts, s.KingdomKey);
Bump(speciesCounts, s.SpeciesId);
}
for (int pass = 0; pass < 2; pass++)
{
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
{
LifeSagaSlot s = list[i];
if (s == null || kept.Contains(s) || IsPin(s))
{
continue;
}
bool kingdomCrowded = CountOf(kingdomCounts, s.KingdomKey) >= 2 && pass == 0;
bool speciesCrowded = CountOf(speciesCounts, s.SpeciesId) >= 2 && pass == 0;
if (pass == 0 && (kingdomCrowded || speciesCrowded))
{
continue;
}
kept.Add(s);
Bump(kingdomCounts, s.KingdomKey);
Bump(speciesCounts, s.SpeciesId);
}
}
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
{
if (!kept.Contains(list[i]))
{
kept.Add(list[i]);
}
}
list.Clear();
list.AddRange(kept);
ApplyChallengerMargin(list, previousIds, now, dull);
}
///
/// Newcomers must beat the weakest displaced non-pin by ChallengerMargin.
///
private static void ApplyChallengerMargin(
List kept,
HashSet previousIds,
float now,
float dull)
{
if (kept == null || previousIds == null || previousIds.Count == 0)
{
return;
}
// Collect displaced living incumbents (non-pins).
List displaced = null;
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot s = Slots[i];
if (s == null || s.UnitId == 0 || IsPin(s))
{
continue;
}
if (IndexOfUnit(kept, s.UnitId) >= 0)
{
continue;
}
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
if (a == null || !a.isAlive())
{
continue;
}
if (displaced == null)
{
displaced = new List(4);
}
displaced.Add(s);
}
if (displaced == null || displaced.Count == 0)
{
return;
}
displaced.Sort((a, b) =>
EffectiveInterest(a, now, dull).CompareTo(EffectiveInterest(b, now, dull)));
for (int d = 0; d < displaced.Count; d++)
{
LifeSagaSlot incumbent = displaced[d];
int worstNewcomer = -1;
float worstScore = float.MaxValue;
for (int i = 0; i < kept.Count; i++)
{
LifeSagaSlot s = kept[i];
if (s == null || IsPin(s) || previousIds.Contains(s.UnitId))
{
continue;
}
float score = EffectiveInterest(s, now, dull);
if (score < worstScore)
{
worstScore = score;
worstNewcomer = i;
}
}
if (worstNewcomer < 0)
{
break;
}
LifeSagaSlot newcomer = kept[worstNewcomer];
if (IsEstablishedMc(incumbent, now)
&& IsOrdinaryChallenger(newcomer)
&& !HasMeaningfulNarrativeEvidence(newcomer))
{
kept[worstNewcomer] = incumbent;
continue;
}
float need = EffectiveInterest(incumbent, now, dull)
+ ReplacementMargin(incumbent, newcomer, now);
if (worstScore + 0.001f < need)
{
kept[worstNewcomer] = incumbent;
}
}
kept.Sort((a, b) => CompareSlots(a, b, now, dull));
while (kept.Count > Cap)
{
// Drop weakest non-pin from the end after re-sort (sort is desc, so end is weakest).
int drop = -1;
for (int i = kept.Count - 1; i >= 0; i--)
{
if (!IsPin(kept[i]))
{
drop = i;
break;
}
}
if (drop < 0)
{
break;
}
kept.RemoveAt(drop);
}
}
private static float ReplacementMargin(
LifeSagaSlot incumbent,
LifeSagaSlot challenger,
float now)
{
float margin = ChallengerMargin;
if (incumbent != null
&& (incumbent.Chapters.Count > 0
|| (incumbent.AdmittedAt > 0f
&& now - incumbent.AdmittedAt >= EstablishedMcSeconds)))
{
margin += EstablishedMcMarginBonus;
}
if (challenger != null
&& challenger.Chapters.Count == 0
&& challenger.Heat < 4f
&& !challenger.IsRecurringAntagonist
&& !challenger.IsSpecialCreature
&& !challenger.IsKing
&& !challenger.IsLeader
&& !challenger.IsClanChief
&& !challenger.IsAlpha)
{
margin += UndevelopedChallengerMarginBonus;
}
return margin;
}
private static bool IsEstablishedMc(LifeSagaSlot slot, float now)
{
return slot != null
&& (slot.Chapters.Count > 0
|| (slot.AdmittedAt > 0f
&& now - slot.AdmittedAt >= EstablishedMcSeconds));
}
private static bool IsOrdinaryChallenger(LifeSagaSlot slot)
{
return slot != null
&& !slot.GameFavorite
&& !slot.Prefer
&& !slot.IsKing
&& !slot.IsLeader
&& !slot.IsClanChief
&& !slot.IsArmyCaptain
&& !slot.IsPlotAuthor
&& !slot.IsFounder
&& !slot.IsSpecialCreature
&& !slot.IsRecurringAntagonist;
}
private static bool HasMeaningfulNarrativeEvidence(LifeSagaSlot slot)
{
if (slot == null)
{
return false;
}
return slot.Chapters.Count > 0
|| slot.Heat >= 4f
|| StoryScheduler.StoryPotential(slot.UnitId) >= 4f;
}
public static bool HarnessProbeReplacementEvidence(out string detail)
{
float now = EstablishedMcSeconds + 2f;
var incumbent = new LifeSagaSlot
{
UnitId = 991201,
AdmittedAt = 1f
};
var ordinary = new LifeSagaSlot
{
UnitId = 991202,
AdmittedAt = now,
Heat = 0f
};
bool ordinaryBlocked = IsEstablishedMc(incumbent, now)
&& IsOrdinaryChallenger(ordinary)
&& !HasMeaningfulNarrativeEvidence(ordinary);
ordinary.Heat = 4f;
bool developedAllowed = HasMeaningfulNarrativeEvidence(ordinary);
ordinary.Heat = 0f;
ordinary.IsSpecialCreature = true;
bool specialAllowed = !IsOrdinaryChallenger(ordinary);
detail = "ordinaryBlocked=" + ordinaryBlocked
+ " developedAllowed=" + developedAllowed
+ " specialAllowed=" + specialAllowed;
return ordinaryBlocked && developedAllowed && specialAllowed;
}
private static int CompareSlots(LifeSagaSlot a, LifeSagaSlot b, float now, float dull)
{
if (a == null && b == null)
{
return 0;
}
if (a == null)
{
return 1;
}
if (b == null)
{
return -1;
}
int pin = (IsPin(b) ? 1 : 0).CompareTo(IsPin(a) ? 1 : 0);
if (pin != 0)
{
return pin;
}
int prefer = (b.Prefer ? 1 : 0).CompareTo(a.Prefer ? 1 : 0);
if (prefer != 0)
{
return prefer;
}
int fav = (b.GameFavorite ? 1 : 0).CompareTo(a.GameFavorite ? 1 : 0);
if (fav != 0)
{
return fav;
}
float ai = EffectiveInterest(a, now, dull);
float bi = EffectiveInterest(b, now, dull);
int cmp = bi.CompareTo(ai);
if (cmp != 0)
{
return cmp;
}
int touched = b.TouchedAt.CompareTo(a.TouchedAt);
return touched != 0 ? touched : a.UnitId.CompareTo(b.UnitId);
}
public static float EffectiveInterest(LifeSagaSlot s, float now = -1f, float dull = -1f)
{
if (s == null)
{
return 0f;
}
if (now < 0f)
{
now = Time.unscaledTime;
}
if (dull < 0f)
{
dull = DullSeconds();
}
float standing = s.Standing;
float heat = s.Heat;
float ageAnchor = Mathf.Max(s.AdmittedAt, Mathf.Max(s.TouchedAt, s.LastChapterAt));
if (ageAnchor <= 0f)
{
ageAnchor = now;
}
float age = now - ageAnchor;
if (age > 0f && dull > 0f && !IsPin(s))
{
// Heat decays; standing softens after dull window so idle early admits age out.
float heatFactor = Mathf.Clamp01(1f - (age / (dull * 1.25f)));
heat *= heatFactor;
if (age > dull)
{
standing *= 0.35f;
}
}
float storyPotential = Mathf.Min(12f, StoryScheduler.StoryPotential(s.UnitId) * 0.8f);
float score = standing + heat + storyPotential;
if (s.GameFavorite)
{
score += 50f;
}
if (s.Prefer)
{
score += 8f;
}
return score;
}
private static float DullSeconds()
{
StoryWeights sw = InterestScoringConfig.Story;
return sw.sagaDullSeconds > 0f ? sw.sagaDullSeconds : 300f;
}
private static void Bump(Dictionary map, string key)
{
if (string.IsNullOrEmpty(key))
{
key = "_";
}
if (!map.TryGetValue(key, out int n))
{
n = 0;
}
map[key] = n + 1;
}
private static int CountOf(Dictionary map, string key)
{
if (string.IsNullOrEmpty(key))
{
key = "_";
}
return map.TryGetValue(key, out int n) ? n : 0;
}
private static int IndexOfUnit(List list, long unitId)
{
for (int i = 0; i < list.Count; i++)
{
if (list[i] != null && list[i].UnitId == unitId)
{
return i;
}
}
return -1;
}
private static int FindWeakestNonPinIndex(float now)
{
float dull = DullSeconds();
int worst = -1;
float worstScore = float.MaxValue;
for (int i = 0; i < Slots.Count; i++)
{
if (Slots[i] == null || IsPin(Slots[i]))
{
continue;
}
float score = EffectiveInterest(Slots[i], now, dull);
if (score < worstScore)
{
worstScore = score;
worst = i;
}
}
return worst;
}
private static bool IsAdmissionCandidate(Actor actor)
{
if (actor == null)
{
return false;
}
try
{
if (actor.isFavorite())
{
return true;
}
}
catch
{
// ignore
}
long actorId = EventFeedUtil.SafeId(actor);
if (actorId != 0
&& NarrativePersistence.IsPreferred(
actorId,
EventFeedUtil.SafeName(actor),
actor.asset != null ? actor.asset.id : ""))
{
return true;
}
if (InterestScoring.IsNotable(actor))
{
return true;
}
if (IsPackAlpha(actor)
|| IsClanChief(actor)
|| IsArmyCaptain(actor)
|| IsActivePlotAuthor(actor)
|| IsFounder(actor)
|| IsSpecialCreature(actor)
|| IsRareCreature(actor)
|| TryGetRecurringAntagonist(actor, out _, out _))
{
return true;
}
return false;
}
public static bool HarnessIsAdmissionCandidate(Actor actor) => IsAdmissionCandidate(actor);
///
/// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles.
///
private static int CompareAdmitPriorityDesc(Actor a, Actor b)
{
return AdmitPriority(b).CompareTo(AdmitPriority(a));
}
///
/// Maintain the best bounded challenger set while walking the world. This avoids
/// sorting every eligible unit together at scan completion.
///
private static void ConsiderWorldScanChallenger(Actor actor)
{
float priority = AdmitPriority(actor);
int insert = 0;
while (insert < ChallengerPriorityScratch.Count
&& ChallengerPriorityScratch[insert] >= priority)
{
insert++;
}
if (insert >= MaxScanAdmits
&& ChallengerScratch.Count >= MaxScanAdmits)
{
return;
}
ChallengerScratch.Insert(insert, actor);
ChallengerPriorityScratch.Insert(insert, priority);
if (ChallengerScratch.Count > MaxScanAdmits)
{
int last = ChallengerScratch.Count - 1;
ChallengerScratch.RemoveAt(last);
ChallengerPriorityScratch.RemoveAt(last);
}
}
private static float AdmitPriority(Actor actor)
{
if (actor == null)
{
return 0f;
}
float p = 0f;
try
{
if (actor.isFavorite())
{
p += 1000f;
}
if (actor.isKing())
{
p += 400f;
}
if (actor.isCityLeader())
{
p += 300f;
}
}
catch
{
// ignore
}
if (InterestScoring.IsNotable(actor))
{
p += 200f;
}
try
{
if (actor.data != null)
{
p += Mathf.Min(80f, actor.data.kills * 0.5f);
}
p += Mathf.Min(60f, actor.renown * 0.25f);
}
catch
{
// ignore
}
if (IsClanChief(actor) || IsArmyCaptain(actor) || IsPackAlpha(actor))
{
p += 120f;
}
if (IsActivePlotAuthor(actor)
|| IsFounder(actor)
|| (IsSpecialCreature(actor) && IsSpeciesSingleton(actor)))
{
p += 80f;
}
if (IsSpecialCreature(actor))
{
p += 45f;
}
int rarePopulation = GetSpeciesPopulation(actor);
if (IsSpecialCreature(actor)
&& rarePopulation > 1
&& rarePopulation <= RareSpeciesMaxPopulation)
{
p += 25f * (RareSpeciesMaxPopulation + 1 - rarePopulation)
/ RareSpeciesMaxPopulation;
}
if (TryGetRecurringAntagonist(actor, out _, out LifeSagaFact antagonistEvidence))
{
p += 150f + Mathf.Min(50f, antagonistEvidence?.Strength ?? 0f);
}
return p;
}
public static bool TryGetRecurringAntagonist(
Actor actor,
out long anchorMcId,
out LifeSagaFact evidence)
{
anchorMcId = 0;
evidence = null;
long actorId = EventFeedUtil.SafeId(actor);
if (actorId == 0)
{
return false;
}
// Both searches are bounded: at most five MCs and each life-memory fact list is capped.
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
if (slot == null || slot.UnitId == 0 || slot.UnitId == actorId)
{
continue;
}
if (LifeSagaMemory.TryGetEarnedRival(slot.UnitId, out LifeSagaFact mcRival)
&& mcRival != null
&& mcRival.OtherId == actorId
&& LifeSagaMemory.IsCredibleRival(mcRival))
{
anchorMcId = slot.UnitId;
evidence = mcRival;
return true;
}
}
if (LifeSagaMemory.TryGetEarnedRival(actorId, out LifeSagaFact actorRival)
&& actorRival != null
&& LifeSagaRoster.IsMc(actorRival.OtherId)
&& LifeSagaMemory.IsCredibleRival(actorRival))
{
anchorMcId = actorRival.OtherId;
evidence = actorRival;
return true;
}
return false;
}
public static bool IsPackAlpha(Actor actor)
{
if (!UsesPackLanguage(actor))
{
return false;
}
try
{
Family family = actor.family;
if (family == null)
{
return false;
}
Actor alpha = family.getAlpha();
return alpha != null && alpha == actor;
}
catch
{
return false;
}
}
///
/// WorldBox uses Family/alpha storage for civilized bloodlines as well as creature packs.
/// Only non-civilized species should surface that implementation detail as pack language.
///
public static bool UsesPackLanguage(Actor actor)
{
try
{
return actor != null
&& actor.asset != null
&& !actor.asset.civ;
}
catch
{
return false;
}
}
public static bool IsClanChief(Actor actor)
{
if (actor == null)
{
return false;
}
try
{
bool hasClan = false;
try
{
hasClan = actor.hasClan();
}
catch
{
hasClan = ReadMember(actor, "clan", "get_clan") != null;
}
if (!hasClan)
{
return false;
}
object clan = ReadMember(actor, "clan", "get_clan");
if (clan == null)
{
return false;
}
long chiefId = ReadLongMember(clan, "chief_id", "get_chief_id");
if (chiefId == 0)
{
object chief = ReadMember(clan, "chief", "getChief", "get_chief");
if (chief is Actor chiefActor)
{
return chiefActor == actor;
}
return false;
}
long self = EventFeedUtil.SafeId(actor);
return self != 0 && chiefId == self;
}
catch
{
return false;
}
}
public static bool IsArmyCaptain(Actor actor)
{
if (actor == null)
{
return false;
}
try
{
object flag = ReadMember(actor, "is_army_captain", "get_is_army_captain");
if (flag is bool b && b)
{
return true;
}
}
catch
{
// fall through
}
try
{
object army = ReadMember(actor, "army", "get_army");
if (army == null)
{
return false;
}
object captain = ReadMember(army, "captain", "getCaptain", "get_captain");
return captain is Actor cap && cap == actor;
}
catch
{
return false;
}
}
private static readonly Dictionary MemberCache =
new Dictionary(128);
private static object ReadMember(object target, params string[] names)
{
if (target == null || names == null)
{
return null;
}
Type t = target.GetType();
for (int i = 0; i < names.Length; i++)
{
string name = names[i];
if (string.IsNullOrEmpty(name))
{
continue;
}
string cacheKey = t.FullName + ":" + name;
if (!MemberCache.TryGetValue(cacheKey, out MemberInfo cached))
{
cached = (MemberInfo)t.GetField(name)
?? t.GetProperty(name)
?? (MemberInfo)t.GetMethod(name, Type.EmptyTypes);
MemberCache[cacheKey] = cached; // may be null - negative cache
}
if (cached == null)
{
continue;
}
try
{
if (cached is FieldInfo fi)
{
object value = fi.GetValue(target);
if (value != null)
{
return value;
}
}
else if (cached is PropertyInfo pi)
{
object value = pi.GetValue(target, null);
if (value != null)
{
return value;
}
}
else if (cached is MethodInfo mi)
{
object value = mi.Invoke(target, null);
if (value != null)
{
return value;
}
}
}
catch
{
// try next
}
}
return null;
}
private static long ReadLongMember(object target, params string[] names)
{
object value = ReadMember(target, names);
if (value is long l)
{
return l;
}
if (value is int i)
{
return i;
}
if (value is uint u)
{
return u;
}
return 0;
}
public static bool IsActivePlotAuthor(Actor actor)
{
if (actor == null)
{
return false;
}
long id = EventFeedUtil.SafeId(actor);
return id != 0 && PlotAuthorCache.Contains(id);
}
public static bool IsFounder(Actor actor)
{
if (actor == null)
{
return false;
}
try
{
Family family = actor.family;
if (family != null)
{
Actor first = family.getFounderFirst();
Actor second = family.getFounderSecond();
if ((first != null && first == actor) || (second != null && second == actor))
{
return true;
}
}
}
catch
{
// ignore
}
try
{
Kingdom kingdom = actor.kingdom;
if (kingdom != null && ActorMatchesFounder(kingdom, actor))
{
return true;
}
}
catch
{
// ignore
}
try
{
City city = actor.city;
if (city != null && ActorMatchesFounder(city, actor))
{
return true;
}
}
catch
{
// ignore
}
return false;
}
public static bool IsSpeciesSingleton(Actor actor)
{
return GetSpeciesPopulation(actor) == 1;
}
public static bool IsRareCreature(Actor actor)
{
if (!IsSpecialCreature(actor))
{
return false;
}
int population = GetSpeciesPopulation(actor);
return population > 0 && population <= RareSpeciesMaxPopulation;
}
public static bool IsSpecialCreature(Actor actor)
{
string id = actor?.asset != null ? actor.asset.id : "";
if (string.IsNullOrEmpty(id))
{
return false;
}
id = id.Trim().ToLowerInvariant();
return id == "dragon"
|| id == "zombie_dragon"
|| id == "druid"
|| id == "necromancer"
|| id == "evil_mage"
|| id == "white_mage";
}
private static int GetSpeciesPopulation(Actor actor)
{
if (actor?.asset == null)
{
return 0;
}
string speciesId = actor.asset.id ?? "";
if (string.IsNullOrEmpty(speciesId))
{
return 0;
}
if (SpeciesCountCache.TryGetValue(speciesId, out int population))
{
return population;
}
// Count only far enough to distinguish singleton/rare from common. The result is
// retained for the current role-cache/world-scan cycle.
population = 0;
try
{
if (actor.asset.units != null)
{
foreach (Actor sameSpecies in actor.asset.units)
{
if (sameSpecies != null
&& sameSpecies.isAlive()
&& ++population > RareSpeciesMaxPopulation)
{
break;
}
}
}
}
catch
{
population = RareSpeciesMaxPopulation + 1;
}
SpeciesCountCache[speciesId] = population;
return population;
}
private static bool ActorMatchesFounder(object meta, Actor actor)
{
if (meta == null || actor == null)
{
return false;
}
long self = EventFeedUtil.SafeId(actor);
if (self == 0)
{
return false;
}
try
{
foreach (string name in new[] { "founder", "creator", "getFounder", "getFounderFirst" })
{
object value = meta.GetType().GetField(name)?.GetValue(meta)
?? meta.GetType().GetProperty(name)?.GetValue(meta, null)
?? meta.GetType().GetMethod(name, Type.EmptyTypes)?.Invoke(meta, null);
if (value is Actor found && found == actor)
{
return true;
}
}
}
catch
{
// ignore
}
try
{
foreach (string name in new[] { "founder_id", "get_founder_id", "main_founder_id_1" })
{
object value = meta.GetType().GetField(name)?.GetValue(meta)
?? meta.GetType().GetProperty(name)?.GetValue(meta, null)
?? meta.GetType().GetMethod(name, Type.EmptyTypes)?.Invoke(meta, null);
long fid = 0;
if (value is long l)
{
fid = l;
}
else if (value is int i)
{
fid = i;
}
if (fid != 0 && fid == self)
{
return true;
}
}
}
catch
{
// ignore
}
return false;
}
private static void EnsureRoleCache(float now, bool force)
{
if (!force && now - _roleCacheAt < 2.4f)
{
return;
}
_roleCacheAt = now;
PlotAuthorCache.Clear();
SpeciesCountCache.Clear();
try
{
if (World.world?.plots == null)
{
return;
}
foreach (Plot plot in World.world.plots)
{
if (plot == null)
{
continue;
}
try
{
if (!plot.isActive())
{
continue;
}
}
catch
{
// treat as active when isActive unavailable
}
Actor author = null;
try
{
author = plot.getAuthor();
}
catch
{
author = null;
}
long id = EventFeedUtil.SafeId(author);
if (id != 0)
{
PlotAuthorCache.Add(id);
}
}
}
catch
{
// ignore
}
}
private static LifeSagaSlot BuildSlot(
Actor actor,
float now,
LifeSagaAdmissionReason reason = LifeSagaAdmissionReason.None)
{
LifeSagaSlot s = new LifeSagaSlot();
s.AdmittedAt = now;
s.TouchedAt = now;
s.Heat = 0f;
RefreshSlot(s, actor, now);
s.Prefer = NarrativePersistence.IsPreferred(
s.UnitId, s.DisplayName, s.SpeciesId);
s.AdmissionReason = reason != LifeSagaAdmissionReason.None
? reason
: InferAdmissionReason(s);
return s;
}
private static void RefreshSlot(LifeSagaSlot s, Actor actor, float now)
{
bool existed = s.UnitId != 0 && s.UnitId == EventFeedUtil.SafeId(actor);
bool wasKing = s.IsKing;
bool wasLeader = s.IsLeader;
bool wasAlpha = s.IsAlpha;
bool wasClanChief = s.IsClanChief;
bool wasArmyCaptain = s.IsArmyCaptain;
s.UnitId = EventFeedUtil.SafeId(actor);
s.DisplayName = SafeName(actor);
bool gameFav = false;
try
{
gameFav = actor.isFavorite();
}
catch
{
gameFav = false;
}
s.GameFavorite = gameFav;
InterestMetadataSnapshot meta = InterestScoring.GetOrBuildMeta(actor, now);
s.SpeciesId = meta?.SpeciesId ?? (actor.asset != null ? actor.asset.id : "");
s.KingdomKey = meta?.KingdomKey ?? "";
s.CityKey = meta?.CityKey ?? "";
s.IsKing = meta != null && meta.King;
s.IsLeader = meta != null && meta.Leader;
s.IsAlpha = IsPackAlpha(actor);
s.IsClanChief = IsClanChief(actor);
s.IsArmyCaptain = IsArmyCaptain(actor);
s.IsPlotAuthor = IsActivePlotAuthor(actor);
s.IsFounder = IsFounder(actor);
s.SpeciesPopulation = GetSpeciesPopulation(actor);
s.IsSingleton = s.SpeciesPopulation == 1;
s.IsSpecialCreature = IsSpecialCreature(actor);
s.IsRareCreature = s.IsSpecialCreature
&& s.SpeciesPopulation > 0
&& s.SpeciesPopulation <= RareSpeciesMaxPopulation;
s.IsRecurringAntagonist = TryGetRecurringAntagonist(
actor,
out s.AntagonistAnchorMcId,
out LifeSagaFact antagonistEvidence);
s.AntagonistEvidence = antagonistEvidence?.Note ?? "";
s.Kills = meta?.Kills ?? 0;
s.Renown = meta?.Renown ?? 0f;
// Standing from flags already resolved above - avoid a second clan/alpha/founder reflection walk.
s.Standing = StandingFromFlags(s);
if (s.AdmittedAt <= 0f)
{
s.AdmittedAt = now;
}
if (s.AdmissionReason == LifeSagaAdmissionReason.None)
{
s.AdmissionReason = InferAdmissionReason(s);
}
if (existed)
{
NoteRisingRoles(
actor,
wasKing,
wasLeader,
wasAlpha,
wasClanChief,
wasArmyCaptain,
s.IsKing,
s.IsLeader,
s.IsAlpha,
s.IsClanChief,
s.IsArmyCaptain);
}
}
private static float StandingFromFlags(LifeSagaSlot s)
{
float score = 1f;
if (s == null)
{
return score;
}
if (s.GameFavorite)
{
score += 12f;
}
if (s.IsKing)
{
score += 10f;
}
else if (s.IsLeader)
{
score += 6f;
}
if (s.IsClanChief)
{
score += 8f;
}
if (s.IsAlpha)
{
score += 7f;
}
if (s.IsPlotAuthor)
{
score += 7f;
}
if (s.IsSingleton && s.IsSpecialCreature)
{
score += 6f;
}
else if (s.IsSingleton)
{
score += 1f;
}
else if (s.IsRareCreature)
{
score += RareCreatureStandingBonus;
}
if (s.IsSpecialCreature)
{
score += SpecialCreatureStandingBonus;
}
if (s.IsRecurringAntagonist)
{
score += RecurringAntagonistStandingBonus;
}
if (s.IsFounder)
{
score += 5f;
}
if (s.IsArmyCaptain)
{
score += 4f;
}
score += Mathf.Min(8f, s.Kills * 0.05f);
score += Mathf.Min(4f, s.Renown / 100f);
// StandoutTraitScore skipped here - reflection-heavy and ran every Refill×Cap.
return score;
}
private static void NoteRisingRoles(
Actor actor,
bool wasKing,
bool wasLeader,
bool wasAlpha,
bool wasClanChief,
bool wasArmyCaptain,
bool isKing,
bool isLeader,
bool isAlpha,
bool isClanChief,
bool isArmyCaptain)
{
if (actor == null)
{
return;
}
if (!wasKing && isKing)
{
LifeSagaMemory.RecordRole(actor, "become_king", "roster_live");
}
if (!wasLeader && isLeader)
{
LifeSagaMemory.RecordRole(actor, "become_leader", "roster_live");
}
if (!wasAlpha && isAlpha)
{
LifeSagaMemory.RecordRole(actor, "become_alpha", "roster_live");
}
if (!wasClanChief && isClanChief)
{
LifeSagaMemory.RecordRole(actor, "become_clan_chief", "roster_live");
}
if (!wasArmyCaptain && isArmyCaptain)
{
LifeSagaMemory.RecordRole(actor, "become_army_captain", "roster_live");
}
}
///
/// Harness: simulate a live role rising edge on an existing roster slot (does not mutate game offices).
///
public static bool HarnessSimulateRoleRise(Actor actor, string roleId)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(roleId))
{
return false;
}
long id = EventFeedUtil.SafeId(actor);
LifeSagaSlot s = Get(id);
if (s == null)
{
return false;
}
string role = roleId.Trim().ToLowerInvariant();
bool wasKing = s.IsKing;
bool wasLeader = s.IsLeader;
bool wasAlpha = s.IsAlpha;
bool wasClanChief = s.IsClanChief;
bool wasArmyCaptain = s.IsArmyCaptain;
if (role.IndexOf("king", System.StringComparison.Ordinal) >= 0)
{
s.IsKing = true;
}
else if (role.IndexOf("leader", System.StringComparison.Ordinal) >= 0)
{
s.IsLeader = true;
}
else if (role.IndexOf("alpha", System.StringComparison.Ordinal) >= 0)
{
s.IsAlpha = true;
}
else if (role.IndexOf("chief", System.StringComparison.Ordinal) >= 0
|| role.IndexOf("clan", System.StringComparison.Ordinal) >= 0)
{
s.IsClanChief = true;
}
else if (role.IndexOf("captain", System.StringComparison.Ordinal) >= 0
|| role.IndexOf("army", System.StringComparison.Ordinal) >= 0)
{
s.IsArmyCaptain = true;
}
else
{
return false;
}
NoteRisingRoles(
actor,
wasKing,
wasLeader,
wasAlpha,
wasClanChief,
wasArmyCaptain,
s.IsKing,
s.IsLeader,
s.IsAlpha,
s.IsClanChief,
s.IsArmyCaptain);
_dirty = true;
return true;
}
private static LifeSagaAdmissionReason InferAdmissionReason(LifeSagaSlot s)
{
if (s == null)
{
return LifeSagaAdmissionReason.None;
}
if (s.GameFavorite) return LifeSagaAdmissionReason.Favorite;
if (s.IsKing) return LifeSagaAdmissionReason.King;
if (s.IsClanChief) return LifeSagaAdmissionReason.ClanChief;
if (s.IsLeader) return LifeSagaAdmissionReason.CityLeader;
if (s.IsAlpha) return LifeSagaAdmissionReason.Alpha;
if (s.IsArmyCaptain) return LifeSagaAdmissionReason.ArmyCaptain;
if (s.IsFounder) return LifeSagaAdmissionReason.Founder;
if (s.IsPlotAuthor) return LifeSagaAdmissionReason.PlotAuthor;
if (s.IsSingleton && s.IsSpecialCreature) return LifeSagaAdmissionReason.Singleton;
if (s.IsRecurringAntagonist) return LifeSagaAdmissionReason.RecurringAntagonist;
if (s.IsSpecialCreature) return LifeSagaAdmissionReason.SpecialCreature;
if (s.IsRareCreature) return LifeSagaAdmissionReason.RareCreature;
if (s.Kills > 0) return LifeSagaAdmissionReason.NotableKills;
if (s.Renown > 0f) return LifeSagaAdmissionReason.NotableRenown;
return LifeSagaAdmissionReason.NotableLife;
}
private static float BaseInterest(Actor actor)
{
float score = 1f;
try
{
if (actor.isFavorite())
{
score += 12f;
}
if (actor.isKing())
{
score += 10f;
}
else if (actor.isCityLeader())
{
score += 6f;
}
}
catch
{
// ignore
}
if (IsClanChief(actor))
{
score += 8f;
}
if (IsPackAlpha(actor))
{
score += 7f;
}
if (IsActivePlotAuthor(actor))
{
score += 7f;
}
if (IsSpecialCreature(actor) && IsSpeciesSingleton(actor))
{
score += 6f;
}
if (IsFounder(actor))
{
score += 5f;
}
if (IsArmyCaptain(actor))
{
score += 4f;
}
try
{
int kills = actor.data != null ? actor.data.kills : 0;
score += Mathf.Min(8f, kills * 0.05f);
score += Mathf.Min(4f, actor.renown / 100f);
}
catch
{
// ignore
}
score += LifeSagaOverview.StandoutTraitScore(actor) * 0.02f;
return score;
}
private static string CleanChapterLine(string line)
{
if (string.IsNullOrEmpty(line))
{
return "";
}
string trimmed = line.Trim();
// Opaque pair/pack tip dumps are not chapter prose.
if (trimmed.IndexOf("·", StringComparison.Ordinal) >= 0
|| trimmed.StartsWith("Pack -", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Duel -", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Outbreak -", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Skirmish -", StringComparison.OrdinalIgnoreCase))
{
// Keep short role words; rewrite combat/pack dumps to kind labels when possible.
if (trimmed.StartsWith("Duel -", StringComparison.OrdinalIgnoreCase))
{
return "Duel";
}
if (trimmed.StartsWith("Pack -", StringComparison.OrdinalIgnoreCase))
{
return "";
}
if (trimmed.StartsWith("Outbreak -", StringComparison.OrdinalIgnoreCase))
{
return "Outbreak";
}
if (trimmed.StartsWith("Skirmish -", StringComparison.OrdinalIgnoreCase))
{
return "Battle";
}
if (trimmed.IndexOf("·", StringComparison.Ordinal) >= 0)
{
// "Name · Becomes pregnant" style - keep trailing prose after last · if short.
int idx = trimmed.LastIndexOf('·');
if (idx >= 0 && idx + 1 < trimmed.Length)
{
string tail = trimmed.Substring(idx + 1).Trim();
if (tail.Length > 0 && tail.Length <= 48)
{
trimmed = tail;
}
else
{
return "";
}
}
}
}
if (trimmed.Length > 72)
{
trimmed = trimmed.Substring(0, 72).TrimEnd() + "…";
}
return trimmed;
}
private static string SafeName(Actor actor)
{
try
{
string n = actor.getName();
if (!string.IsNullOrEmpty(n))
{
return n;
}
}
catch
{
// ignore
}
return actor.asset != null ? actor.asset.id : "Unit";
}
}
/// One life on the saga roster.
public sealed class LifeSagaSlot
{
public long UnitId;
public string DisplayName = "";
public string SpeciesId = "";
public string KingdomKey = "";
public string CityKey = "";
public bool GameFavorite;
public bool Prefer;
public bool IsKing;
public bool IsLeader;
public bool IsAlpha;
public bool IsClanChief;
public bool IsArmyCaptain;
public bool IsPlotAuthor;
public bool IsFounder;
public bool IsSingleton;
public bool IsRareCreature;
public bool IsSpecialCreature;
public int SpeciesPopulation;
public bool IsRecurringAntagonist;
public long AntagonistAnchorMcId;
public string AntagonistEvidence = "";
public int Kills;
public float Renown;
/// Live role/standing recomputed each refresh.
public float Standing;
/// Recent chapter/feature heat; decays with idle time.
public float Heat;
public float AdmittedAt;
public float TouchedAt;
public float LastChapterAt;
public LifeSagaAdmissionReason AdmissionReason;
public readonly List Chapters =
new List(LifeSagaRoster.MaxChapters);
/// Legacy alias for harness / debug - Standing + Heat.
public float Interest
{
get => Standing + Heat;
set => Heat = Mathf.Max(0f, value - Standing);
}
public Actor Resolve() => EventFeedUtil.FindAliveById(UnitId);
}
public enum LifeSagaAdmissionReason
{
None,
Favorite,
King,
ClanChief,
CityLeader,
Alpha,
ArmyCaptain,
Founder,
PlotAuthor,
Singleton,
RecurringAntagonist,
RareCreature,
SpecialCreature,
NotableKills,
NotableRenown,
NotableLife,
EmergingStory
}
public enum LifeSagaChapterKind
{
Unknown,
Combat,
War,
Plot,
Love,
Grief
}
public sealed class LifeSagaChapter
{
public LifeSagaChapterKind Kind;
public string Line = "";
public float At;
}