- Introduce new narrative probes for episode grammar, ensemble balance, and prose redundancy - Implement narrative presentation coverage tracking for better event visibility - Update harness scenarios to include new narrative ingestion and persistence tests - Refactor event emission to include narrative development IDs and presentations - Expand InterestCandidate to support narrative presentation models and roles
435 lines
16 KiB
C#
435 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>World-session narrative state. Updated from developments; independent of Saga roster churn.</summary>
|
|
public static class NarrativeStoryStore
|
|
{
|
|
private const int RecentPerCharacter = 16;
|
|
private const int ConsequencesPerCharacter = 24;
|
|
private static readonly Dictionary<string, NarrativeDevelopment> Developments =
|
|
new Dictionary<string, NarrativeDevelopment>(StringComparer.Ordinal);
|
|
private static readonly Dictionary<long, CharacterStoryState> Characters =
|
|
new Dictionary<long, CharacterStoryState>();
|
|
private static readonly Dictionary<string, NarrativeThread> Threads =
|
|
new Dictionary<string, NarrativeThread>(StringComparer.Ordinal);
|
|
private static readonly Dictionary<string, CharacterConsequence> Consequences =
|
|
new Dictionary<string, CharacterConsequence>(StringComparer.Ordinal);
|
|
private static readonly List<NarrativeThread> ThreadScratch = new List<NarrativeThread>(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<NarrativeDevelopment> AllDevelopments() => Developments.Values;
|
|
internal static IEnumerable<NarrativeThread> AllThreads() => Threads.Values;
|
|
internal static IEnumerable<CharacterConsequence> AllConsequences() => Consequences.Values;
|
|
internal static IEnumerable<CharacterStoryState> 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<NarrativeThread> 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Copies bounded story-potential challengers without scanning the world.</summary>
|
|
public static void CopyEmergingCharacters(List<CharacterStoryState> 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<NarrativeChapter> ChaptersFor(long characterId, int max)
|
|
{
|
|
var chapters = new List<NarrativeChapter>(Mathf.Max(0, max));
|
|
CharacterStoryState state = Character(characterId);
|
|
if (state == null || max <= 0) return chapters;
|
|
var seenThreads = new HashSet<string>(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<NarrativeChapter> 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;
|
|
}
|
|
}
|