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(); _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; } 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 (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 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; }