- 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
1513 lines
57 KiB
C#
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;
|
|
}
|
|
}
|