worldbox-observer-mod/IdleSpectator/Story/LifeSagaRoster.cs
DazedAnon 188d091166 feat(spectator): focus camera on a five-character story ensemble
- Prioritize MCs, related cast, meaningful disasters, and exceptional events
- Show MC connections in the dossier when following related characters
- Promote rare creatures and recurring antagonists through bounded scoring
- Reduce roster churn and repetitive love/combat coverage
- Add performance telemetry, soak auditing, documentation, and harness coverage
2026-07-24 12:54:06 -05:00

2950 lines
78 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Durable dynamic five-character life-saga cast. Owns rail Prefer, diversity, and soft camera bias.
/// Independent of short-arc <see cref="StoryPlanner"/> Clear.
/// </summary>
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<LifeSagaSlot> Slots = new List<LifeSagaSlot>(Cap);
private static readonly List<LifeSagaSlot> Scratch = new List<LifeSagaSlot>(48);
private static readonly List<Actor> ChallengerScratch = new List<Actor>(64);
private static readonly List<float> ChallengerPriorityScratch = new List<float>(64);
private static readonly List<Actor> UnitScanSnapshot = new List<Actor>(512);
private static readonly HashSet<long> ScanSeen = new HashSet<long>();
private static readonly HashSet<long> PreviousIds = new HashSet<long>();
private static readonly HashSet<long> PlotAuthorCache = new HashSet<long>();
private static readonly Dictionary<string, int> SpeciesCountCache = new Dictionary<string, int>();
private static readonly HashSet<long> McOrbitCache = new HashSet<long>();
private static readonly List<long> TouchScratch = new List<long>(8);
private static readonly List<CharacterStoryState> NarrativeChallengerScratch =
new List<CharacterStoryState>(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<Actor> _liveScanSource;
private static float _scanStartedAt;
private static int _scanPinCount;
private enum WorldScanPhase
{
None,
Walking,
Commit
}
/// <summary>Units probed per frame during amortized world admission.</summary>
private const int WorldScanUnitsPerFrame = 12;
/// <summary>Full challenger dossiers built per frame after a world scan.</summary>
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;
/// <summary>Cadence for starting a spread world admission scan.</summary>
private const float WorldScanIntervalSeconds = 3.5f;
/// <summary>Last timed world-scan refill cost in ms (hitch probe).</summary>
public static float LastWorldScanMs { get; private set; }
/// <summary>Last timed soft refill cost in ms (hitch probe).</summary>
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();
_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<LifeSagaSlot> 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;
}
/// <summary>Harness: force a living unit onto the roster (bypasses admission for tests).</summary>
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;
}
/// <summary>
/// Harness: run a one-shot world admission scan while Busy normally suppresses discovery.
/// </summary>
public static void HarnessWorldScan()
{
float now = Time.unscaledTime;
EnsureRoleCache(now, force: true);
Scratch.Clear();
PreviousIds.Clear();
HashSet<long> seen = new HashSet<long>();
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;
}
/// <summary>
/// Harness helper: set Standing/Heat for a roster unit so replace tests can age them out.
/// </summary>
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;
}
/// <summary>Among candidates, pick Prefer'd MC, else any MC, else null.</summary>
public static Actor PreferRosterUnit(IEnumerable<Actor> 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;
}
/// <summary>
/// Best living current MC for quiet camera grounding. Bounded by the five-slot roster;
/// unlike the world activity scanner this never walks the population.
/// </summary>
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));
}
/// <summary>Prefer=4, CrossMC=3, MC=2, MC cast=1, else 0.</summary>
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;
}
/// <summary>Near-tie breaker: Prefer > CrossMC > MC > MC cast > neither.</summary>
public static int CompareSoftBias(InterestCandidate a, InterestCandidate b)
{
return SoftBiasRank(b).CompareTo(SoftBiasRank(a));
}
/// <summary>True when unit is in a roster MC's live story orbit (not themselves an MC).</summary>
public static bool IsMcCast(long unitId)
{
if (unitId == 0 || IsMc(unitId))
{
return false;
}
return McOrbitCache.Contains(unitId);
}
/// <summary>
/// Roster MCs touched by the tip for rail involved chrome.
/// Bounded to subject/related/follow/pair/short-arc cast - not mass ParticipantIds.
/// </summary>
public static void CollectTouchedMcIds(InterestCandidate tip, List<long> 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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Spread admission discovery across frames so a 2k-unit map cannot hitch on one Tick.
/// </summary>
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<Actor> 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;
}
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;
}
}
/// <summary>
/// Soft featured tips heat existing MCs only - never admit nobodies.
/// </summary>
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);
}
}
/// <summary>Soft heat bump for an existing roster MC (cross-MC moments).</summary>
public static void NoteFeaturedUnit(long unitId, float heat = 1f)
{
if (unitId == 0)
{
return;
}
NoteUnit(unitId, heat, Time.unscaledTime);
}
/// <summary>
/// Challenge Cap with a living unit. Admission roles always may compete;
/// ordinary characters only when coherent story momentum qualifies them.
/// </summary>
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<LifeSagaSlot> list, float now, HashSet<long> previousIds)
{
float dull = DullSeconds();
list.Sort((a, b) => CompareSlots(a, b, now, dull));
if (list.Count <= Cap)
{
ApplyChallengerMargin(list, previousIds, now, dull);
return;
}
Dictionary<string, int> kingdomCounts = new Dictionary<string, int>();
Dictionary<string, int> speciesCounts = new Dictionary<string, int>();
List<LifeSagaSlot> kept = new List<LifeSagaSlot>(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);
}
/// <summary>
/// Newcomers must beat the weakest displaced non-pin by ChallengerMargin.
/// </summary>
private static void ApplyChallengerMargin(
List<LifeSagaSlot> kept,
HashSet<long> previousIds,
float now,
float dull)
{
if (kept == null || previousIds == null || previousIds.Count == 0)
{
return;
}
// Collect displaced living incumbents (non-pins).
List<LifeSagaSlot> 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<LifeSagaSlot>(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<string, int> 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<string, int> map, string key)
{
if (string.IsNullOrEmpty(key))
{
key = "_";
}
return map.TryGetValue(key, out int n) ? n : 0;
}
private static int IndexOfUnit(List<LifeSagaSlot> 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);
/// <summary>
/// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles.
/// </summary>
private static int CompareAdmitPriorityDesc(Actor a, Actor b)
{
return AdmitPriority(b).CompareTo(AdmitPriority(a));
}
/// <summary>
/// Maintain the best bounded challenger set while walking the world. This avoids
/// sorting every eligible unit together at scan completion.
/// </summary>
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 (actor == null)
{
return false;
}
try
{
Family family = actor.family;
if (family == null)
{
return false;
}
Actor alpha = family.getAlpha();
return alpha != null && alpha == actor;
}
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<string, MemberInfo> MemberCache =
new Dictionary<string, MemberInfo>(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");
}
}
/// <summary>
/// Harness: simulate a live role rising edge on an existing roster slot (does not mutate game offices).
/// </summary>
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";
}
}
/// <summary>One life on the saga roster.</summary>
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;
/// <summary>Live role/standing recomputed each refresh.</summary>
public float Standing;
/// <summary>Recent chapter/feature heat; decays with idle time.</summary>
public float Heat;
public float AdmittedAt;
public float TouchedAt;
public float LastChapterAt;
public LifeSagaAdmissionReason AdmissionReason;
public readonly List<LifeSagaChapter> Chapters =
new List<LifeSagaChapter>(LifeSagaRoster.MaxChapters);
/// <summary>Legacy alias for harness / debug - Standing + Heat.</summary>
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;
}