worldbox-observer-mod/IdleSpectator/Narrative/NarrativeStoryStore.cs
DazedAnon c9000531fc feat(happiness): enhance happiness event handling and telemetry tracking
- Introduce bounded happiness probe to improve event diagnostics
- Update happiness drain logic to limit processed occurrences per tick
- Enhance telemetry for happiness processing times and maximum items
- Refactor narrative development to include new home-related events
- Improve narrative thread handling for home establishment and recovery
2026-07-23 22:45:18 -05:00

1513 lines
57 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 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 = 650;
private const int MaxRuntimeConsequences = 1200;
private const int MaxRuntimeCharacters = 2000;
private const float RuntimeCompactionIntervalSeconds = 60f;
internal const int SchedulerCandidateLimit = 96;
internal const int SchedulerCopyLimit = 128;
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);
private static readonly Dictionary<string, NarrativeThread> SchedulerCandidates =
new Dictionary<string, NarrativeThread>(SchedulerCandidateLimit, StringComparer.Ordinal);
private static readonly HashSet<string> SchedulerCopyIds =
new HashSet<string>(StringComparer.Ordinal);
private static readonly List<LifeSagaSlot> RosterScratch =
new List<LifeSagaSlot>(LifeSagaRoster.Cap);
private static readonly Dictionary<long, HashSet<string>> OpenConflictsByCharacter =
new Dictionary<long, HashSet<string>>();
private static readonly Dictionary<string, HashSet<long>> ConflictCastByThread =
new Dictionary<string, HashSet<long>>(StringComparer.Ordinal);
private static readonly List<string> IdScratch = new List<string>(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<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()
{
RepairConsistency();
Revision++;
StoryScheduler.Clear();
NarrativeTelemetry.Clear();
NarrativePresentationCoverage.Clear();
}
/// <summary>
/// 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.
/// </summary>
internal static int ResolveCombatThreadsForDeath(
long deceasedId,
NarrativeDevelopment death)
{
if (deceasedId == 0)
{
return 0;
}
if (!OpenConflictsByCharacter.TryGetValue(deceasedId, out HashSet<string> 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<NarrativeThread> into)
{
if (into == null) return;
into.Clear();
SchedulerCopyIds.Clear();
IdScratch.Clear();
float now = Time.unscaledTime;
foreach (KeyValuePair<string, NarrativeThread> 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();
}
/// <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);
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;
}
/// <summary>
/// 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.
/// </summary>
internal static int RepairConsistency()
{
var deadIds = new HashSet<long>();
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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<long>();
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<string>(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);
}
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<string>(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<CharacterConsequence>(Consequences.Values);
rankedConsequences.Sort((a, b) =>
{
bool aProtected = a != null && protectedCharacters.Contains(a.CharacterId);
bool bProtected = b != null && protectedCharacters.Contains(b.CharacterId);
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<string>(StringComparer.Ordinal);
for (int i = 0;
i < rankedConsequences.Count
&& retainedConsequenceIds.Count < MaxRuntimeConsequences;
i++)
{
CharacterConsequence consequence = rankedConsequences[i];
if (consequence == null || string.IsNullOrEmpty(consequence.Id)
|| string.IsNullOrEmpty(consequence.DevelopmentId)
|| !Developments.ContainsKey(consequence.DevelopmentId))
{
continue;
}
if (!retainedDevelopmentIds.Contains(consequence.DevelopmentId)
&& retainedDevelopmentIds.Count >= MaxRuntimeDevelopments)
{
continue;
}
retainedDevelopmentIds.Add(consequence.DevelopmentId);
retainedConsequenceIds.Add(consequence.Id);
}
var rankedDevelopments = new List<NarrativeDevelopment>(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<long>(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);
if (consequence.OtherId != 0
&& retainedCharacterIds.Count < MaxRuntimeCharacters)
{
retainedCharacterIds.Add(consequence.OtherId);
}
}
var rankedCharacters = new List<CharacterStoryState>(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);
});
for (int i = 0;
i < rankedCharacters.Count
&& retainedCharacterIds.Count < MaxRuntimeCharacters;
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
+ " 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 void AddExistingId<T>(
string id,
Dictionary<string, T> source,
HashSet<string> retained)
{
if (!string.IsNullOrEmpty(id) && source.ContainsKey(id))
{
retained.Add(id);
}
}
private static void AddExistingIds<T>(
List<string> ids,
Dictionary<string, T> source,
HashSet<string> 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<T>(
Dictionary<string, T> source,
HashSet<string> retained)
{
var remove = new List<string>();
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<T>(
Dictionary<long, T> source,
HashSet<long> retained)
{
var remove = new List<long>();
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<NarrativeThread>(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;
for (int i = 0; i < importedDevelopments; i++)
{
string developmentId = "probe:compact:development:" + i;
ImportDevelopment(new NarrativeDevelopment
{
Id = developmentId,
Kind = NarrativeDevelopmentKind.HomeGained,
Function = i == importedDevelopments - 1
? NarrativeFunction.TurningPoint
: NarrativeFunction.CharacterTexture,
SubjectId = i + 1,
OccurredAt = i + 1,
Magnitude = i == importedDevelopments - 1 ? 100f : 5f
});
NarrativeEventStore.Import(new NarrativeEventRecord
{
Id = "event:" + developmentId,
DevelopmentId = developmentId,
SubjectId = i + 1,
ObservedAt = i + 1,
WorldTime = i + 1
});
ImportCharacter(new CharacterStoryState
{
CharacterId = i + 1,
LastMeaningfulChangeAt = i + 1,
StoryPotential = i % 20
});
if (i < importedThreads)
{
var thread = new NarrativeThread
{
Id = "probe:compact:thread:" + i,
Kind = NarrativeThreadKind.Founding,
ProtagonistId = i + 1,
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(i + 1, "protagonist");
ImportThread(thread);
}
}
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 ok = ambientGraphDeferred && authoredBirthProjected && authoredHomeProjected
&& compacted && bounded && graphClosed && newestThreadKept;
detail = "events=" + NarrativeEventStore.Count + "/" + MaxRuntimeEvents
+ " developments=" + Developments.Count + "/" + MaxRuntimeDevelopments
+ " threads=" + Threads.Count + "/" + MaxRuntimeThreads
+ " consequences=" + Consequences.Count + "/" + MaxRuntimeConsequences
+ " characters=" + Characters.Count + "/" + MaxRuntimeCharacters
+ " 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<NarrativeThread> 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<string, NarrativeThread> 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<long>();
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<string> ids))
{
ids = new HashSet<string>(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<long> castIds))
{
return;
}
foreach (long characterId in castIds)
{
if (!OpenConflictsByCharacter.TryGetValue(characterId, out HashSet<string> 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<long> 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<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;
}
}