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 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); 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 void Clear() { Developments.Clear(); Characters.Clear(); Threads.Clear(); Consequences.Clear(); ThreadScratch.Clear(); 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() { foreach (CharacterStoryState state in Characters.Values) { if (state == null) continue; state.OpenThreadIds.RemoveAll(id => !Threads.ContainsKey(id)); state.ResolvedThreadIds.RemoveAll(id => !Threads.ContainsKey(id)); state.RecentDevelopmentIds.RemoveAll(id => !Developments.ContainsKey(id)); state.ConsequenceIds.RemoveAll(id => !Consequences.ContainsKey(id)); } Revision++; StoryScheduler.Clear(); NarrativeTelemetry.Clear(); NarrativePresentationCoverage.Clear(); } 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; CharacterStoryState subject = GetOrCreateCharacter(development.SubjectId, development.Subject); TouchDevelopment(subject, development); if (development.OtherId != 0) { GetOrCreateCharacter(development.OtherId, development.Other); } NarrativeThreadRules.Apply(development); 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); 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); Revision++; return thread; } 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(); foreach (NarrativeThread thread in Threads.Values) { if (thread != null && (thread.IsOpen || (thread.Status == NarrativeThreadStatus.Resolved && Time.unscaledTime - thread.UpdatedAt < 20f))) { into.Add(thread); } } } /// 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); else state.ResolvedThreadIds.Insert(0, thread.Id); } 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; } 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; } }