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;
}
}