using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// World-session narrative state. Updated from developments; independent of Saga roster churn. public static class NarrativeStoryStore { private const int RecentPerCharacter = 16; private const int ConsequencesPerCharacter = 24; private const int OpenThreadsPerCharacter = 16; private const int ResolvedThreadsPerCharacter = 24; private const int DevelopmentsPerThread = 16; private const int MaxRuntimeEvents = 2200; private const int MaxRuntimeDevelopments = 2000; private const int MaxRuntimeThreads = 512; private const int MaxRuntimeConsequences = 1200; private const int MaxRuntimeCharacters = 2000; // Saga/episode consequences are retained independently. Anonymous history competes // for this smaller editorial budget so it cannot pin one character per world event. private const int MaxAmbientRuntimeConsequences = 192; // Character states referenced by retained story material are always kept. Beyond // those, retain only a small bench of strong emerging challengers. private const int MaxEmergingRuntimeCharacters = 64; private const float RuntimeCompactionIntervalSeconds = 60f; internal const int SchedulerCandidateLimit = 96; internal const int SchedulerCopyLimit = 128; private static readonly Dictionary Developments = new Dictionary(StringComparer.Ordinal); private static readonly Dictionary Characters = new Dictionary(); private static readonly Dictionary Threads = new Dictionary(StringComparer.Ordinal); private static readonly Dictionary Consequences = new Dictionary(StringComparer.Ordinal); private static readonly List ThreadScratch = new List(32); private static readonly Dictionary SchedulerCandidates = new Dictionary(SchedulerCandidateLimit, StringComparer.Ordinal); private static readonly HashSet SchedulerCopyIds = new HashSet(StringComparer.Ordinal); private static readonly List RosterScratch = new List(LifeSagaRoster.Cap); private static readonly Dictionary> OpenConflictsByCharacter = new Dictionary>(); private static readonly Dictionary> ConflictCastByThread = new Dictionary>(StringComparer.Ordinal); private static readonly List IdScratch = new List(SchedulerCandidateLimit); private static float _nextRuntimeCompactionAt; public static int Revision { get; private set; } public static int DevelopmentCount => Developments.Count; public static int ThreadCount => Threads.Count; public static int CharacterCount => Characters.Count; public static int ConsequenceCount => Consequences.Count; public static float LastCompactionMs { get; private set; } public static int CompactionCount { get; private set; } public static void Clear() { Developments.Clear(); Characters.Clear(); Threads.Clear(); Consequences.Clear(); ThreadScratch.Clear(); SchedulerCandidates.Clear(); SchedulerCopyIds.Clear(); RosterScratch.Clear(); OpenConflictsByCharacter.Clear(); ConflictCastByThread.Clear(); IdScratch.Clear(); _nextRuntimeCompactionAt = 0f; LastCompactionMs = 0f; CompactionCount = 0; NarrativeEventStore.Clear(); Revision++; StoryScheduler.Clear(); NarrativeTelemetry.Clear(); NarrativePresentationCoverage.Clear(); } public static CharacterStoryState Character(long id) { Characters.TryGetValue(id, out CharacterStoryState state); return state; } public static NarrativeDevelopment Development(string id) { if (string.IsNullOrEmpty(id)) return null; Developments.TryGetValue(id, out NarrativeDevelopment development); return development; } public static NarrativeThread Thread(string id) { if (string.IsNullOrEmpty(id)) return null; Threads.TryGetValue(id, out NarrativeThread thread); return thread; } public static CharacterConsequence Consequence(string id) { if (string.IsNullOrEmpty(id)) return null; Consequences.TryGetValue(id, out CharacterConsequence consequence); return consequence; } internal static IEnumerable AllDevelopments() => Developments.Values; internal static IEnumerable AllThreads() => Threads.Values; internal static IEnumerable AllConsequences() => Consequences.Values; internal static IEnumerable AllCharacters() => Characters.Values; internal static void ImportDevelopment(NarrativeDevelopment development) { if (development != null && development.SubjectId != 0 && !string.IsNullOrEmpty(development.Id)) { Developments[development.Id] = development; } } internal static void ImportThread(NarrativeThread thread) { if (thread != null && !string.IsNullOrEmpty(thread.Id)) { Threads[thread.Id] = thread; } } internal static void ImportConsequence(CharacterConsequence consequence) { if (consequence != null && !string.IsNullOrEmpty(consequence.Id)) { Consequences[consequence.Id] = consequence; } } internal static void ImportCharacter(CharacterStoryState character) { if (character != null && character.CharacterId != 0) { Characters[character.CharacterId] = character; } } internal static void FinishImport() { RepairConsistency(); Revision++; StoryScheduler.Clear(); NarrativeTelemetry.Clear(); NarrativePresentationCoverage.Clear(); } /// /// A death is a terminal answer to every live personal conflict involving that actor. /// This keeps a kill/death from becoming a permanent unresolved-question multiplier. /// internal static int ResolveCombatThreadsForDeath( long deceasedId, NarrativeDevelopment death) { if (deceasedId == 0) { return 0; } if (!OpenConflictsByCharacter.TryGetValue(deceasedId, out HashSet indexed) || indexed.Count == 0) { return 0; } IdScratch.Clear(); foreach (string threadId in indexed) IdScratch.Add(threadId); int resolved = 0; for (int i = 0; i < IdScratch.Count; i++) { NarrativeThread thread = Thread(IdScratch[i]); if (!IsOpenConflict(thread) || !thread.HasCast(deceasedId)) continue; ResolveConflictThread(thread, death, deceasedId); resolved++; } IdScratch.Clear(); if (resolved > 0) { Revision++; } return resolved; } public static bool Record(NarrativeDevelopment development) { if (development == null || development.SubjectId == 0 || string.IsNullOrEmpty(development.Id)) { return false; } if (Developments.TryGetValue(development.Id, out NarrativeDevelopment existing)) { existing.OccurredAt = Mathf.Max(existing.OccurredAt, development.OccurredAt); existing.Magnitude = Mathf.Max(existing.Magnitude, development.Magnitude); existing.Confidence = Mathf.Max(existing.Confidence, development.Confidence); existing.IsNewStateChange = false; return false; } Developments[development.Id] = development; if (ShouldProjectDevelopment(development)) { CharacterStoryState subject = GetOrCreateCharacter(development.SubjectId, development.Subject); TouchDevelopment(subject, development); if (development.OtherId != 0) { GetOrCreateCharacter(development.OtherId, development.Other); } NarrativeThreadRules.Apply(development); } MaybeCompactRuntimeGraph(); Revision++; return true; } public static NarrativeThread OpenOrUpdateThread( string id, NarrativeThreadKind kind, long protagonistId, string anchor, NarrativeDevelopment development, string centralQuestion, NarrativePhase phase, NarrativeThreadStatus status) { if (string.IsNullOrEmpty(id) || protagonistId == 0 || development == null) return null; bool created = !Threads.TryGetValue(id, out NarrativeThread thread); if (created) { thread = new NarrativeThread { Id = id, Kind = kind, ProtagonistId = protagonistId, AnchorKey = anchor ?? "", OpenedByDevelopmentId = development.Id, OpenedAt = development.OccurredAt, Coherence = 1f, Novelty = 1f }; Threads[id] = thread; } thread.Kind = kind; thread.CentralQuestion = centralQuestion ?? thread.CentralQuestion; thread.Phase = phase; thread.Status = status; thread.UpdatedAt = development.OccurredAt; thread.LatestMeaningfulChangeId = development.Id; if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id); TrimThreadDevelopmentHistory(thread); thread.AddCast(protagonistId, "protagonist"); if (development.OtherId != 0) thread.AddCast(development.OtherId, CastRole(development.Kind)); thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function)); thread.Urgency = Mathf.Max(thread.Urgency, UrgencyFor(development)); LifeSagaIdentity protagonistIdentity = protagonistId == development.SubjectId ? development.Subject : (protagonistId == development.OtherId ? development.Other : default); CharacterStoryState state = GetOrCreateCharacter(protagonistId, protagonistIdentity); MoveThreadReference(state, thread); RecalculatePotential(state); TrackThread(thread); Revision++; return thread; } private static void TrimThreadDevelopmentHistory(NarrativeThread thread) { if (thread == null) return; while (thread.DevelopmentIds.Count > DevelopmentsPerThread) { // The opening evidence remains useful for episode/Legacy context. Evict the // oldest middle beat while retaining the newest state transition. int removeAt = thread.DevelopmentIds.Count > 1 ? 1 : 0; thread.DevelopmentIds.RemoveAt(removeAt); } } public static void AddConsequence(CharacterConsequence consequence) { if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return; if (Consequences.ContainsKey(consequence.Id)) return; Consequences[consequence.Id] = consequence; CharacterStoryState state = GetOrCreateCharacter(consequence.CharacterId, default); state.ConsequenceIds.Insert(0, consequence.Id); while (state.ConsequenceIds.Count > ConsequencesPerCharacter) { state.ConsequenceIds.RemoveAt(state.ConsequenceIds.Count - 1); } RecalculatePotential(state); Revision++; } public static void CopyOpenThreads(List into) { if (into == null) return; into.Clear(); SchedulerCopyIds.Clear(); IdScratch.Clear(); float now = Time.unscaledTime; foreach (KeyValuePair pair in SchedulerCandidates) { NarrativeThread thread = pair.Value; if (!IsSchedulerEligible(thread, now)) { IdScratch.Add(pair.Key); continue; } AddSchedulerCopy(into, thread); } for (int i = 0; i < IdScratch.Count; i++) SchedulerCandidates.Remove(IdScratch[i]); IdScratch.Clear(); // A bounded recent index handles ordinary challengers. Always fold the current // main cast back in so a quieter long-running MC thread cannot be displaced by // a burst of one-shot world events. LifeSagaRoster.CopySlots(RosterScratch); for (int i = 0; i < RosterScratch.Count && into.Count < SchedulerCopyLimit; i++) { CharacterStoryState state = Character(RosterScratch[i].UnitId); if (state == null) continue; for (int j = 0; j < state.OpenThreadIds.Count && into.Count < SchedulerCopyLimit; j++) { NarrativeThread thread = Thread(state.OpenThreadIds[j]); if (IsSchedulerEligible(thread, now)) AddSchedulerCopy(into, thread); } } RosterScratch.Clear(); SchedulerCopyIds.Clear(); } /// Copies bounded story-potential challengers without scanning the world. public static void CopyEmergingCharacters(List into, float minimumPotential, int max) { if (into == null) return; into.Clear(); float now = Time.unscaledTime; foreach (CharacterStoryState state in Characters.Values) { if (state != null && state.CharacterId != 0 && EffectiveStoryPotential(state, now) >= minimumPotential) { into.Add(state); } } into.Sort((a, b) => EffectiveStoryPotential(b, now).CompareTo(EffectiveStoryPotential(a, now))); if (max > 0 && into.Count > max) into.RemoveRange(max, into.Count - max); } public static float EffectiveStoryPotential(CharacterStoryState state, float now = -1f) { if (state == null) return 0f; if (now < 0f) now = Time.unscaledTime; float age = Mathf.Max(0f, now - state.LastMeaningfulChangeAt); // Narrative momentum has a long half-life, but is not a permanent celebrity badge. float freshness = Mathf.Pow(0.5f, age / 300f); return state.StoryPotential * freshness; } public static NarrativeDevelopment LatestFor(long characterId, NarrativeDevelopmentKind kind, float maxAge) { CharacterStoryState state = Character(characterId); if (state == null) return null; float now = Time.unscaledTime; for (int i = 0; i < state.RecentDevelopmentIds.Count; i++) { NarrativeDevelopment development = Development(state.RecentDevelopmentIds[i]); if (development == null || development.Kind != kind) continue; if (maxAge > 0f && now - development.OccurredAt > maxAge) continue; return development; } return null; } public static List ChaptersFor(long characterId, int max) { var chapters = new List(Mathf.Max(0, max)); CharacterStoryState state = Character(characterId); if (state == null || max <= 0) return chapters; var seenThreads = new HashSet(StringComparer.Ordinal); for (int i = 0; i < state.ConsequenceIds.Count && chapters.Count < max; i++) { CharacterConsequence consequence = Consequence(state.ConsequenceIds[i]); if (consequence == null || consequence.Confidence < 0.75f) continue; string line; if (consequence.Kind == CharacterConsequenceKind.BecameParent) { int children = CountConsequences(characterId, CharacterConsequenceKind.BecameParent); line = children >= 3 ? "Raised a lineage of " + children + " children" : NarrativeProse.ConsequenceLine(consequence); } else { line = NarrativeProse.ConsequenceLine(consequence); } if (string.IsNullOrEmpty(line)) continue; if (!string.IsNullOrEmpty(consequence.ThreadId) && !seenThreads.Add(consequence.ThreadId)) { NarrativeChapter current = FindChapter(chapters, consequence.ThreadId); string merged = NarrativeProse.TryMergeChapter(current?.Line, consequence); if (current != null && !string.IsNullOrEmpty(merged)) { current.Line = merged; current.Importance = Mathf.Max(current.Importance, consequence.Importance); } continue; } chapters.Add(new NarrativeChapter { Id = consequence.Id, ThreadId = consequence.ThreadId, Line = line, DateLabel = consequence.DateLabel ?? "", At = consequence.OccurredAt, Importance = consequence.Importance }); } chapters.Sort((a, b) => b.Importance.CompareTo(a.Importance)); return chapters; } public static int CountConsequences(long characterId, CharacterConsequenceKind kind) { CharacterStoryState state = Character(characterId); if (state == null) return 0; int count = 0; for (int i = 0; i < state.ConsequenceIds.Count; i++) { CharacterConsequence c = Consequence(state.ConsequenceIds[i]); if (c != null && c.Kind == kind) count++; } return count; } private static CharacterStoryState GetOrCreateCharacter(long id, LifeSagaIdentity identity) { if (!Characters.TryGetValue(id, out CharacterStoryState state)) { state = new CharacterStoryState { CharacterId = id, Identity = identity }; Characters[id] = state; } else if (identity.Id != 0) { state.Identity = identity; } return state; } private static void TouchDevelopment(CharacterStoryState state, NarrativeDevelopment development) { state.RecentDevelopmentIds.Insert(0, development.Id); while (state.RecentDevelopmentIds.Count > RecentPerCharacter) { state.RecentDevelopmentIds.RemoveAt(state.RecentDevelopmentIds.Count - 1); } state.LastMeaningfulChangeAt = development.OccurredAt; state.StoryMomentum = Mathf.Min(12f, state.StoryMomentum + MomentumFor(development.Function)); RecalculatePotential(state); } private static void MoveThreadReference(CharacterStoryState state, NarrativeThread thread) { state.OpenThreadIds.Remove(thread.Id); state.ResolvedThreadIds.Remove(thread.Id); if (thread.IsOpen) { state.OpenThreadIds.Insert(0, thread.Id); while (state.OpenThreadIds.Count > OpenThreadsPerCharacter) { state.OpenThreadIds.RemoveAt(state.OpenThreadIds.Count - 1); } } else { state.ResolvedThreadIds.Insert(0, thread.Id); while (state.ResolvedThreadIds.Count > ResolvedThreadsPerCharacter) { state.ResolvedThreadIds.RemoveAt(state.ResolvedThreadIds.Count - 1); } } } private static void RecalculatePotential(CharacterStoryState state) { if (state == null) return; float unresolved = state.OpenThreadIds.Count * 2.5f; float consequences = Mathf.Min(6f, state.ConsequenceIds.Count * 0.8f); state.StoryPotential = state.StoryMomentum + unresolved + consequences; } /// /// Rebuild derived character references and hot-path indexes from the retained graph, /// close legacy terminal threads, and recalculate potential instead of trusting cached DTO values. /// internal static int RepairConsistency() { var deadIds = new HashSet(); foreach (NarrativeDevelopment development in Developments.Values) { if (development != null && development.Kind == NarrativeDevelopmentKind.Death && development.SubjectId != 0) { deadIds.Add(development.SubjectId); } } int repaired = 0; foreach (NarrativeThread thread in Threads.Values) { TrimThreadDevelopmentHistory(thread); if (thread != null && thread.Kind == NarrativeThreadKind.Founding && thread.IsOpen && thread.Phase == NarrativePhase.Outcome) { thread.Status = NarrativeThreadStatus.Resolved; thread.Resolution = string.IsNullOrEmpty(thread.Resolution) ? "home established" : thread.Resolution; repaired++; } if (!IsOpenConflict(thread)) { continue; } NarrativeDevelopment latest = Development(thread.LatestMeaningfulChangeId); long deceased = DeadCastMember(thread, deadIds); if (latest?.Kind != NarrativeDevelopmentKind.Kill && deceased == 0) { continue; } ResolveConflictThread(thread, latest, deceased); repaired++; } foreach (CharacterStoryState state in Characters.Values) { if (state == null) continue; state.OpenThreadIds.Clear(); state.ResolvedThreadIds.Clear(); state.RecentDevelopmentIds.RemoveAll(id => !Developments.ContainsKey(id)); state.ConsequenceIds.RemoveAll(id => !Consequences.ContainsKey(id)); } ThreadScratch.Clear(); foreach (NarrativeThread thread in Threads.Values) { if (thread != null && thread.ProtagonistId != 0) { ThreadScratch.Add(thread); } } ThreadScratch.Sort((a, b) => a.UpdatedAt.CompareTo(b.UpdatedAt)); for (int i = 0; i < ThreadScratch.Count; i++) { NarrativeThread thread = ThreadScratch[i]; CharacterStoryState state = GetOrCreateCharacter( thread.ProtagonistId, default); MoveThreadReference(state, thread); } ThreadScratch.Clear(); foreach (CharacterStoryState state in Characters.Values) { RecalculatePotential(state); } RebuildThreadIndexes(); return repaired; } private static void MaybeCompactRuntimeGraph() { bool overHighWater = NarrativeEventStore.Count > MaxRuntimeEvents * 3 / 2 || Developments.Count > MaxRuntimeDevelopments * 3 / 2 || Threads.Count > MaxRuntimeThreads * 3 / 2 || Consequences.Count > MaxRuntimeConsequences * 3 / 2 || Characters.Count > MaxRuntimeCharacters * 3 / 2; if (!overHighWater) { return; } float now = Time.unscaledTime; bool emergency = NarrativeEventStore.Count > MaxRuntimeEvents * 2 || Developments.Count > MaxRuntimeDevelopments * 2 || Threads.Count > MaxRuntimeThreads * 2 || Consequences.Count > MaxRuntimeConsequences * 2 || Characters.Count > MaxRuntimeCharacters * 2; if (!emergency && now < _nextRuntimeCompactionAt) { return; } CompactRuntimeGraph(force: false); } /// /// Ambient evidence remains in the event/development stores for persistence and dedupe. /// A full character/thread/consequence graph is useful only once the event touches the /// authored cast; projecting every anonymous death, breakup, friendship, move, and birth /// created hundreds of disposable objects between minute-level compactions. /// private static bool ShouldProjectDevelopment(NarrativeDevelopment development) { if (development == null || !DefersAnonymousGraphProjection(development.Kind) || (!string.IsNullOrEmpty(development.EvidenceSource) && development.EvidenceSource.StartsWith( "harness", StringComparison.OrdinalIgnoreCase))) { return true; } if (LifeSagaRoster.IsMc(development.SubjectId) || LifeSagaRoster.IsPrefer(development.SubjectId) || LifeSagaRoster.IsMcCast(development.SubjectId) || LifeSagaRoster.IsMc(development.OtherId) || LifeSagaRoster.IsPrefer(development.OtherId) || LifeSagaRoster.IsMcCast(development.OtherId)) { return true; } EpisodePlan episode = StoryScheduler.ActiveEpisode; if (episode == null) { return false; } if (episode.ProtagonistId == development.SubjectId || episode.ProtagonistId == development.OtherId) { return true; } NarrativeThread active = Thread(episode.ThreadId); return active != null && (active.HasCast(development.SubjectId) || active.HasCast(development.OtherId)); } private static bool DefersAnonymousGraphProjection(NarrativeDevelopmentKind kind) { switch (kind) { case NarrativeDevelopmentKind.BondFormed: case NarrativeDevelopmentKind.BondBroken: case NarrativeDevelopmentKind.ChildBorn: case NarrativeDevelopmentKind.FriendFormed: case NarrativeDevelopmentKind.Death: case NarrativeDevelopmentKind.HomeGained: case NarrativeDevelopmentKind.HomeLost: return true; default: return false; } } /// /// Bounds the live narrative graph while protecting the active episode and the /// Saga cast. Persistence already compacts its sidecar; this prevents long-running /// worlds from retaining every anonymous one-shot thread until the next reload. /// private static bool CompactRuntimeGraph(bool force) { bool needsCompaction = NarrativeEventStore.Count > MaxRuntimeEvents || Developments.Count > MaxRuntimeDevelopments || Threads.Count > MaxRuntimeThreads || Consequences.Count > MaxRuntimeConsequences || Characters.Count > MaxRuntimeCharacters; if (!force && !needsCompaction) { return false; } var stopwatch = System.Diagnostics.Stopwatch.StartNew(); int beforeEvents = NarrativeEventStore.Count; int beforeDevelopments = Developments.Count; int beforeThreads = Threads.Count; int beforeConsequences = Consequences.Count; int beforeCharacters = Characters.Count; float now = Time.unscaledTime; var protectedCharacters = new HashSet(); LifeSagaRoster.CopySlots(RosterScratch); for (int i = 0; i < RosterScratch.Count; i++) { long id = RosterScratch[i]?.UnitId ?? 0; if (id != 0) protectedCharacters.Add(id); } RosterScratch.Clear(); EpisodePlan episode = StoryScheduler.ActiveEpisode; if (episode != null && episode.ProtagonistId != 0) { protectedCharacters.Add(episode.ProtagonistId); } var retainedThreadIds = new HashSet(StringComparer.Ordinal); if (episode != null && !string.IsNullOrEmpty(episode.ThreadId) && Threads.ContainsKey(episode.ThreadId)) { retainedThreadIds.Add(episode.ThreadId); } foreach (long characterId in protectedCharacters) { CharacterStoryState state = Character(characterId); if (state == null) continue; AddExistingIds(state.OpenThreadIds, Threads, retainedThreadIds); AddExistingIds(state.ResolvedThreadIds, Threads, retainedThreadIds); } // Snapshot the authored spine before ambient challengers fill the runtime budget. // Consequences belonging to these threads remain lossless. var protectedThreadIds = new HashSet(retainedThreadIds, StringComparer.Ordinal); ThreadScratch.Clear(); foreach (NarrativeThread thread in Threads.Values) { if (thread != null && !retainedThreadIds.Contains(thread.Id)) { ThreadScratch.Add(thread); } } ThreadScratch.Sort((a, b) => ThreadRetentionScore(b, now).CompareTo(ThreadRetentionScore(a, now))); for (int i = 0; i < ThreadScratch.Count && retainedThreadIds.Count < MaxRuntimeThreads; i++) { retainedThreadIds.Add(ThreadScratch[i].Id); } ThreadScratch.Clear(); var retainedDevelopmentIds = new HashSet(StringComparer.Ordinal); foreach (string threadId in retainedThreadIds) { NarrativeThread thread = Thread(threadId); AddExistingId(thread?.LatestMeaningfulChangeId, Developments, retainedDevelopmentIds); } foreach (long characterId in protectedCharacters) { CharacterStoryState state = Character(characterId); if (state == null) continue; AddExistingIds( state.RecentDevelopmentIds, Developments, retainedDevelopmentIds, MaxRuntimeDevelopments); } var rankedConsequences = new List(Consequences.Values); rankedConsequences.Sort((a, b) => { bool aProtected = IsProtectedConsequence( a, protectedCharacters, protectedThreadIds); bool bProtected = IsProtectedConsequence( b, protectedCharacters, protectedThreadIds); if (aProtected != bProtected) return aProtected ? -1 : 1; int importance = (b?.Importance ?? float.MinValue).CompareTo( a?.Importance ?? float.MinValue); if (importance != 0) return importance; return (b?.OccurredAt ?? float.MinValue).CompareTo( a?.OccurredAt ?? float.MinValue); }); var retainedConsequenceIds = new HashSet(StringComparer.Ordinal); int protectedConsequences = 0; int ambientConsequences = 0; for (int i = 0; i < rankedConsequences.Count && retainedConsequenceIds.Count < MaxRuntimeConsequences; i++) { CharacterConsequence consequence = rankedConsequences[i]; bool protectedConsequence = IsProtectedConsequence( consequence, protectedCharacters, protectedThreadIds); if (!protectedConsequence && ambientConsequences >= MaxAmbientRuntimeConsequences) { continue; } if (!TryRetainConsequence( consequence, retainedDevelopmentIds, retainedConsequenceIds)) { continue; } if (protectedConsequence) { protectedConsequences++; } else { ambientConsequences++; } } var rankedDevelopments = new List(Developments.Values); rankedDevelopments.Sort((a, b) => { float aScore = DevelopmentRetentionScore(a); float bScore = DevelopmentRetentionScore(b); int score = bScore.CompareTo(aScore); if (score != 0) return score; return (b?.OccurredAt ?? float.MinValue).CompareTo( a?.OccurredAt ?? float.MinValue); }); for (int i = 0; i < rankedDevelopments.Count && retainedDevelopmentIds.Count < MaxRuntimeDevelopments; i++) { NarrativeDevelopment development = rankedDevelopments[i]; if (development != null && !string.IsNullOrEmpty(development.Id)) { retainedDevelopmentIds.Add(development.Id); } } RemoveUnretained(Threads, retainedThreadIds); RemoveUnretained(Consequences, retainedConsequenceIds); RemoveUnretained(Developments, retainedDevelopmentIds); foreach (NarrativeThread thread in Threads.Values) { if (thread == null) continue; thread.DevelopmentIds.RemoveAll(id => !retainedDevelopmentIds.Contains(id)); if (!string.IsNullOrEmpty(thread.LatestMeaningfulChangeId) && retainedDevelopmentIds.Contains(thread.LatestMeaningfulChangeId) && !thread.DevelopmentIds.Contains(thread.LatestMeaningfulChangeId)) { thread.DevelopmentIds.Add(thread.LatestMeaningfulChangeId); } if (!retainedDevelopmentIds.Contains(thread.OpenedByDevelopmentId)) { thread.OpenedByDevelopmentId = thread.DevelopmentIds.Count > 0 ? thread.DevelopmentIds[0] : thread.LatestMeaningfulChangeId; } TrimThreadDevelopmentHistory(thread); } foreach (CharacterConsequence consequence in Consequences.Values) { if (consequence != null && !retainedThreadIds.Contains(consequence.ThreadId)) { consequence.ThreadId = ""; } } NarrativeEventStore.Compact(retainedDevelopmentIds, MaxRuntimeEvents); var retainedCharacterIds = new HashSet(protectedCharacters); foreach (NarrativeThread thread in Threads.Values) { if (thread != null && thread.ProtagonistId != 0) { retainedCharacterIds.Add(thread.ProtagonistId); } } foreach (CharacterConsequence consequence in Consequences.Values) { if (consequence == null) continue; if (consequence.CharacterId != 0) retainedCharacterIds.Add(consequence.CharacterId); } var rankedCharacters = new List(Characters.Values); rankedCharacters.Sort((a, b) => { float aScore = EffectiveStoryPotential(a, now); float bScore = EffectiveStoryPotential(b, now); int score = bScore.CompareTo(aScore); if (score != 0) return score; return (b?.LastMeaningfulChangeAt ?? float.MinValue).CompareTo( a?.LastMeaningfulChangeAt ?? float.MinValue); }); int emergingLimit = Math.Min( MaxRuntimeCharacters, retainedCharacterIds.Count + MaxEmergingRuntimeCharacters); for (int i = 0; i < rankedCharacters.Count && retainedCharacterIds.Count < emergingLimit; i++) { CharacterStoryState state = rankedCharacters[i]; if (state != null && state.CharacterId != 0) { retainedCharacterIds.Add(state.CharacterId); } } RemoveUnretained(Characters, retainedCharacterIds); RepairConsistency(); Revision++; stopwatch.Stop(); LastCompactionMs = (float)stopwatch.Elapsed.TotalMilliseconds; CompactionCount++; _nextRuntimeCompactionAt = now + RuntimeCompactionIntervalSeconds; NeoModLoader.services.LogService.LogInfo( "[IdleSpectator][NARRATIVE] runtime compact " + "events=" + beforeEvents + "->" + NarrativeEventStore.Count + " developments=" + beforeDevelopments + "->" + Developments.Count + " threads=" + beforeThreads + "->" + Threads.Count + " consequences=" + beforeConsequences + "->" + Consequences.Count + " consequenceMix=" + protectedConsequences + "/" + ambientConsequences + " characters=" + beforeCharacters + "->" + Characters.Count + " ms=" + LastCompactionMs.ToString("0.0")); return true; } private static float ThreadRetentionScore(NarrativeThread thread, float now) { if (thread == null) return float.MinValue; float age = Mathf.Max(0f, now - thread.UpdatedAt); float freshness = Mathf.Max(0f, 60f - age / 10f); float open = thread.IsOpen ? 45f : 0f; float phase = thread.Phase == NarrativePhase.TurningPoint ? 24f : thread.Phase == NarrativePhase.Outcome ? 18f : thread.Phase == NarrativePhase.Consequence ? 14f : 0f; return freshness + open + phase + thread.Urgency * 3f + thread.Momentum + thread.CoverageDebt; } private static float DevelopmentRetentionScore(NarrativeDevelopment development) { if (development == null) return float.MinValue; float function; switch (development.Function) { case NarrativeFunction.TurningPoint: function = 60f; break; case NarrativeFunction.Resolution: function = 55f; break; case NarrativeFunction.Consequence: function = 50f; break; case NarrativeFunction.Escalation: function = 40f; break; case NarrativeFunction.Development: function = 30f; break; case NarrativeFunction.ThreadOpener: function = 25f; break; default: function = 0f; break; } return function + development.Magnitude * 0.2f + development.Confidence * 5f; } private static bool IsProtectedConsequence( CharacterConsequence consequence, HashSet protectedCharacters, HashSet protectedThreadIds) { if (consequence == null) { return false; } return (protectedCharacters != null && (protectedCharacters.Contains(consequence.CharacterId) || (consequence.OtherId != 0 && protectedCharacters.Contains(consequence.OtherId)))) || (protectedThreadIds != null && !string.IsNullOrEmpty(consequence.ThreadId) && protectedThreadIds.Contains(consequence.ThreadId)); } private static bool TryRetainConsequence( CharacterConsequence consequence, HashSet retainedDevelopmentIds, HashSet retainedConsequenceIds) { if (consequence == null || string.IsNullOrEmpty(consequence.Id) || string.IsNullOrEmpty(consequence.DevelopmentId) || !Developments.ContainsKey(consequence.DevelopmentId) || (!retainedDevelopmentIds.Contains(consequence.DevelopmentId) && retainedDevelopmentIds.Count >= MaxRuntimeDevelopments)) { return false; } retainedDevelopmentIds.Add(consequence.DevelopmentId); retainedConsequenceIds.Add(consequence.Id); return true; } private static void AddExistingId( string id, Dictionary source, HashSet retained) { if (!string.IsNullOrEmpty(id) && source.ContainsKey(id)) { retained.Add(id); } } private static void AddExistingIds( List ids, Dictionary source, HashSet retained, int max = int.MaxValue) { if (ids == null) return; for (int i = 0; i < ids.Count && retained.Count < max; i++) { AddExistingId(ids[i], source, retained); } } private static void RemoveUnretained( Dictionary source, HashSet retained) { var remove = new List(); foreach (string id in source.Keys) { if (!retained.Contains(id)) remove.Add(id); } for (int i = 0; i < remove.Count; i++) source.Remove(remove[i]); } private static void RemoveUnretained( Dictionary source, HashSet retained) { var remove = new List(); foreach (long id in source.Keys) { if (!retained.Contains(id)) remove.Add(id); } for (int i = 0; i < remove.Count; i++) source.Remove(remove[i]); } public static bool HarnessProbeCombatLifecycle(out string detail) { Clear(); var rival = new NarrativeDevelopment { Id = "rival:101:202", Kind = NarrativeDevelopmentKind.RivalEncounter, Function = NarrativeFunction.Escalation, SubjectId = 101, Subject = new LifeSagaIdentity { Id = 101, Name = "Aro", SpeciesId = "human" }, OtherId = 202, Other = new LifeSagaIdentity { Id = 202, Name = "Bex", SpeciesId = "orc" }, OccurredAt = 1f, Magnitude = 60f }; Record(rival); NarrativeThread thread = Thread("thread:conflict:conflict:101:202:101"); bool rivalryOpen = thread != null && thread.IsOpen; var kill = new NarrativeDevelopment { Id = "kill:101:202", Kind = NarrativeDevelopmentKind.Kill, Function = NarrativeFunction.Consequence, SubjectId = 101, Subject = rival.Subject, OtherId = 202, Other = rival.Other, OccurredAt = 2f, Magnitude = 90f }; Record(kill); thread = Thread("thread:conflict:conflict:101:202:101"); CharacterStoryState killer = Character(101); bool killResolved = thread != null && !thread.IsOpen && thread.Phase == NarrativePhase.Outcome && killer != null && killer.OpenThreadIds.Count == 0; var stale = new NarrativeThread { Id = "thread:conflict:legacy:303:404", Kind = NarrativeThreadKind.PersonalCombat, ProtagonistId = 303, LatestMeaningfulChangeId = kill.Id, Status = NarrativeThreadStatus.Active, Phase = NarrativePhase.TurningPoint, UpdatedAt = 3f }; stale.AddCast(303, "protagonist"); stale.AddCast(404, "opponent"); ImportThread(stale); ImportCharacter(new CharacterStoryState { CharacterId = 303, StoryMomentum = 2f, StoryPotential = 99f }); int repaired = RepairConsistency(); CharacterStoryState migrated = Character(303); bool staleRepaired = !stale.IsOpen && migrated != null && migrated.OpenThreadIds.Count == 0 && migrated.StoryPotential < 20f; bool ok = rivalryOpen && killResolved && staleRepaired && repaired == 1; detail = "rivalOpen=" + rivalryOpen + " killResolved=" + killResolved + " repaired=" + repaired + " migratedPotential=" + (migrated?.StoryPotential.ToString("0.0") ?? "none") + " pass=" + ok; Clear(); return ok; } public static bool HarnessProbeSchedulerBounds(out string detail) { Clear(); const int imported = 600; for (int i = 0; i < imported; i++) { var thread = new NarrativeThread { Id = "probe:scheduler:" + i, Kind = NarrativeThreadKind.Partnership, ProtagonistId = i + 1, Status = NarrativeThreadStatus.Active, Phase = NarrativePhase.Pressure, UpdatedAt = i, Momentum = i % 8, Urgency = i % 5 }; thread.AddCast(i + 1, "protagonist"); ImportThread(thread); } RepairConsistency(); var candidates = new List(SchedulerCopyLimit); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); for (int i = 0; i < 200; i++) CopyOpenThreads(candidates); stopwatch.Stop(); // The recent-thread index is capped at SchedulerCandidateLimit, then active // main-cast threads may be folded back in up to SchedulerCopyLimit. A populated // live roster therefore legitimately produces more than 96 candidates. bool bounded = candidates.Count <= SchedulerCopyLimit; StoryScheduler.Clear(); StoryScheduler.Tick(Time.unscaledTime + 1f); float schedulerMs = StoryScheduler.LastTickMs; int schedulerCandidates = StoryScheduler.LastCandidateCount; bool schedulerFast = schedulerCandidates <= SchedulerCopyLimit && schedulerMs < 50f; var home = new NarrativeDevelopment { Id = "probe:home:700", Kind = NarrativeDevelopmentKind.HomeFounded, Function = NarrativeFunction.Consequence, SubjectId = 700, Subject = new LifeSagaIdentity { Id = 700, Name = "Founder", SpeciesId = "human" }, CityKey = "probe-city", OccurredAt = Time.unscaledTime, Magnitude = 80f }; Record(home); NarrativeThread homeThread = Thread("thread:home:probe-city:700"); bool foundingTerminal = homeThread != null && !homeThread.IsOpen && homeThread.Status == NarrativeThreadStatus.Resolved; bool fast = stopwatch.ElapsedMilliseconds < 50; bool ok = bounded && schedulerFast && foundingTerminal && fast; detail = "imported=" + imported + " candidates=" + candidates.Count + "/" + SchedulerCopyLimit + " copies200Ms=" + stopwatch.ElapsedMilliseconds + " scheduler=" + schedulerMs.ToString("0.00") + "ms/" + schedulerCandidates + " foundingTerminal=" + foundingTerminal + " pass=" + ok; Clear(); return ok; } public static bool HarnessProbeRuntimeCompaction(out string detail) { Clear(); Record(new NarrativeDevelopment { Id = "probe:compact:ambient-child", Kind = NarrativeDevelopmentKind.ChildBorn, Function = NarrativeFunction.Consequence, SubjectId = 9000001, OtherId = 9000002, FamilyKey = "probe:ambient-family", EvidenceSource = "natural_soak", OccurredAt = 1f, Magnitude = 76f }); Record(new NarrativeDevelopment { Id = "probe:compact:ambient-death", Kind = NarrativeDevelopmentKind.Death, Function = NarrativeFunction.Resolution, SubjectId = 9000003, OtherId = 9000004, EvidenceSource = "natural_soak", OccurredAt = 1.1f, Magnitude = 100f }); Record(new NarrativeDevelopment { Id = "probe:compact:ambient-home", Kind = NarrativeDevelopmentKind.HomeGained, Function = NarrativeFunction.Development, SubjectId = 9000005, EvidenceSource = "natural_soak", OccurredAt = 1.2f, Magnitude = 64f }); bool ambientGraphDeferred = ThreadCount == 0 && CharacterCount == 0; Record(new NarrativeDevelopment { Id = "probe:compact:authored-child", Kind = NarrativeDevelopmentKind.ChildBorn, Function = NarrativeFunction.Consequence, SubjectId = 9000011, OtherId = 9000012, FamilyKey = "probe:authored-family", EvidenceSource = "harness_compaction", OccurredAt = 2f, Magnitude = 76f }); bool authoredBirthProjected = ThreadCount == 2 && CharacterCount == 2; Record(new NarrativeDevelopment { Id = "probe:compact:authored-home", Kind = NarrativeDevelopmentKind.HomeGained, Function = NarrativeFunction.Development, SubjectId = 9000013, CorrelationKey = "probe-home", EvidenceSource = "harness_compaction", OccurredAt = 2.1f, Magnitude = 64f }); NarrativeThread homeThread = Thread("thread:home:probe-home:9000013"); bool authoredHomeProjected = homeThread != null && homeThread.Kind == NarrativeThreadKind.Recovery && !homeThread.IsOpen; Clear(); const int importedDevelopments = 2300; const int importedThreads = 900; const int importedConsequences = 1300; // Keep synthetic ids outside normal WorldBox actor-id ranges. Low ids could collide // with the live five-MC roster, falsely protecting five ambient consequences and // making this deterministic compaction probe depend on the currently loaded world. const long importedCharacterBase = 9100000; for (int i = 0; i < importedDevelopments; i++) { long characterId = importedCharacterBase + i; string developmentId = "probe:compact:development:" + i; ImportDevelopment(new NarrativeDevelopment { Id = developmentId, Kind = NarrativeDevelopmentKind.HomeGained, Function = i == importedDevelopments - 1 ? NarrativeFunction.TurningPoint : NarrativeFunction.CharacterTexture, SubjectId = characterId, OccurredAt = i + 1, Magnitude = i == importedDevelopments - 1 ? 100f : 5f }); NarrativeEventStore.Import(new NarrativeEventRecord { Id = "event:" + developmentId, DevelopmentId = developmentId, SubjectId = characterId, ObservedAt = i + 1, WorldTime = i + 1 }); ImportCharacter(new CharacterStoryState { CharacterId = characterId, LastMeaningfulChangeAt = i + 1, StoryPotential = i % 20 }); if (i < importedThreads) { var thread = new NarrativeThread { Id = "probe:compact:thread:" + i, Kind = NarrativeThreadKind.Founding, ProtagonistId = characterId, OpenedByDevelopmentId = developmentId, LatestMeaningfulChangeId = developmentId, Status = NarrativeThreadStatus.Resolved, Phase = NarrativePhase.Outcome, OpenedAt = i + 1, UpdatedAt = i + 1, Momentum = i / 100f }; thread.DevelopmentIds.Add(developmentId); thread.AddCast(characterId, "protagonist"); ImportThread(thread); } if (i < importedConsequences) { ImportConsequence(new CharacterConsequence { Id = "probe:compact:consequence:" + i, CharacterId = characterId, DevelopmentId = developmentId, Kind = CharacterConsequenceKind.FoundHome, OccurredAt = i + 1, Importance = 50f }); } } bool compacted = CompactRuntimeGraph(force: true); bool graphClosed = true; foreach (NarrativeThread thread in Threads.Values) { if (thread == null || Development(thread.LatestMeaningfulChangeId) == null) { graphClosed = false; break; } } bool newestThreadKept = Thread("probe:compact:thread:899") != null; bool bounded = NarrativeEventStore.Count <= MaxRuntimeEvents && Developments.Count <= MaxRuntimeDevelopments && Threads.Count <= MaxRuntimeThreads && Consequences.Count <= MaxRuntimeConsequences && Characters.Count <= MaxRuntimeCharacters; bool leanCharacters = Characters.Count <= MaxRuntimeThreads + MaxAmbientRuntimeConsequences + MaxEmergingRuntimeCharacters; bool leanConsequences = Consequences.Count <= MaxAmbientRuntimeConsequences; bool ok = ambientGraphDeferred && authoredBirthProjected && authoredHomeProjected && compacted && bounded && leanCharacters && leanConsequences && graphClosed && newestThreadKept; detail = "events=" + NarrativeEventStore.Count + "/" + MaxRuntimeEvents + " developments=" + Developments.Count + "/" + MaxRuntimeDevelopments + " threads=" + Threads.Count + "/" + MaxRuntimeThreads + " consequences=" + Consequences.Count + "/" + MaxRuntimeConsequences + " ambientBudget=" + MaxAmbientRuntimeConsequences + " characters=" + Characters.Count + "/" + MaxRuntimeCharacters + " leanCharacters=" + leanCharacters + " leanConsequences=" + leanConsequences + " newest=" + newestThreadKept + " closed=" + graphClosed + " deferred=" + ambientGraphDeferred + " authoredBirth=" + authoredBirthProjected + " authoredHome=" + authoredHomeProjected + " ms=" + LastCompactionMs.ToString("0.0") + " pass=" + ok; Clear(); return ok; } private static void AddSchedulerCopy(List into, NarrativeThread thread) { if (thread == null || into.Count >= SchedulerCopyLimit || !SchedulerCopyIds.Add(thread.Id)) { return; } into.Add(thread); } private static bool IsSchedulerEligible(NarrativeThread thread, float now) { if (thread == null) return false; if (thread.IsOpen) return true; float age = now - thread.UpdatedAt; return thread.Status == NarrativeThreadStatus.Resolved && age >= 0f && age < 20f; } private static void TrackThread(NarrativeThread thread) { if (thread == null || string.IsNullOrEmpty(thread.Id)) return; TrackConflict(thread); if (!IsSchedulerEligible(thread, Time.unscaledTime)) { SchedulerCandidates.Remove(thread.Id); return; } SchedulerCandidates[thread.Id] = thread; if (SchedulerCandidates.Count <= SchedulerCandidateLimit) return; string weakestId = null; float weakest = float.MaxValue; foreach (KeyValuePair pair in SchedulerCandidates) { float priority = SchedulerIndexPriority(pair.Value); if (priority < weakest) { weakest = priority; weakestId = pair.Key; } } if (!string.IsNullOrEmpty(weakestId)) SchedulerCandidates.Remove(weakestId); } private static float SchedulerIndexPriority(NarrativeThread thread) { if (thread == null) return float.MinValue; float open = thread.IsOpen ? 50f : 0f; float phase = thread.Phase == NarrativePhase.TurningPoint ? 16f : thread.Phase == NarrativePhase.Outcome ? 12f : thread.Phase == NarrativePhase.Consequence ? 10f : 0f; float oneShot = thread.Kind == NarrativeThreadKind.Founding ? -40f : 0f; return open + phase + oneShot + thread.Urgency + thread.Momentum + thread.UpdatedAt * 0.01f; } private static void RebuildThreadIndexes() { SchedulerCandidates.Clear(); OpenConflictsByCharacter.Clear(); ConflictCastByThread.Clear(); foreach (NarrativeThread thread in Threads.Values) TrackThread(thread); } private static void TrackConflict(NarrativeThread thread) { UntrackConflict(thread?.Id); if (!IsOpenConflict(thread)) return; var castIds = new HashSet(); for (int i = 0; i < thread.Cast.Count; i++) { long characterId = thread.Cast[i]?.CharacterId ?? 0; if (characterId == 0 || !castIds.Add(characterId)) continue; if (!OpenConflictsByCharacter.TryGetValue(characterId, out HashSet ids)) { ids = new HashSet(StringComparer.Ordinal); OpenConflictsByCharacter[characterId] = ids; } ids.Add(thread.Id); } ConflictCastByThread[thread.Id] = castIds; } private static void UntrackConflict(string threadId) { if (string.IsNullOrEmpty(threadId) || !ConflictCastByThread.TryGetValue(threadId, out HashSet castIds)) { return; } foreach (long characterId in castIds) { if (!OpenConflictsByCharacter.TryGetValue(characterId, out HashSet ids)) continue; ids.Remove(threadId); if (ids.Count == 0) OpenConflictsByCharacter.Remove(characterId); } ConflictCastByThread.Remove(threadId); } private static bool IsOpenConflict(NarrativeThread thread) { return thread != null && thread.IsOpen && (thread.Kind == NarrativeThreadKind.PersonalCombat || thread.Kind == NarrativeThreadKind.Rivalry); } private static long DeadCastMember(NarrativeThread thread, HashSet deadIds) { if (thread == null || deadIds == null || deadIds.Count == 0) { return 0; } for (int i = 0; i < thread.Cast.Count; i++) { NarrativeCastRole cast = thread.Cast[i]; if (cast != null && deadIds.Contains(cast.CharacterId)) { return cast.CharacterId; } } return 0; } private static void ResolveConflictThread( NarrativeThread thread, NarrativeDevelopment terminal, long deceasedId) { if (thread == null) { return; } thread.Status = NarrativeThreadStatus.Resolved; thread.Phase = NarrativePhase.Outcome; thread.Resolution = deceasedId != 0 && deceasedId == thread.ProtagonistId ? "protagonist died" : "opponent defeated"; if (terminal != null) { thread.UpdatedAt = Mathf.Max(thread.UpdatedAt, terminal.OccurredAt); thread.LatestMeaningfulChangeId = terminal.Id; if (!thread.DevelopmentIds.Contains(terminal.Id)) { thread.DevelopmentIds.Add(terminal.Id); } TrimThreadDevelopmentHistory(thread); } if (Characters.TryGetValue(thread.ProtagonistId, out CharacterStoryState state)) { MoveThreadReference(state, thread); RecalculatePotential(state); } TrackThread(thread); } private static float MomentumFor(NarrativeFunction function) { switch (function) { case NarrativeFunction.ThreadOpener: return 2f; case NarrativeFunction.Development: return 1.5f; case NarrativeFunction.Escalation: return 2.5f; case NarrativeFunction.TurningPoint: return 4f; case NarrativeFunction.Resolution: return 3.5f; case NarrativeFunction.Consequence: return 3f; default: return 0.25f; } } private static float UrgencyFor(NarrativeDevelopment development) { if (development == null) return 0f; if (development.Function == NarrativeFunction.TurningPoint) return 4f; if (development.Function == NarrativeFunction.Resolution) return 3f; return Mathf.Clamp(development.Magnitude / 25f, 0f, 3f); } private static string CastRole(NarrativeDevelopmentKind kind) { switch (kind) { case NarrativeDevelopmentKind.BondFormed: case NarrativeDevelopmentKind.BondBroken: return "partner"; case NarrativeDevelopmentKind.ChildBorn: return "child"; case NarrativeDevelopmentKind.FriendFormed: return "friend"; case NarrativeDevelopmentKind.Kill: case NarrativeDevelopmentKind.RivalEncounter: return "opponent"; default: return "involved"; } } private static NarrativeChapter FindChapter(List chapters, string threadId) { for (int i = 0; i < chapters.Count; i++) { if (chapters[i] != null && string.Equals(chapters[i].ThreadId, threadId, StringComparison.Ordinal)) { return chapters[i]; } } return null; } }