worldbox-observer-mod/IdleSpectator/Narrative/NarrativePersistence.cs
DazedAnon ff4b40d00e feat(narrative): enhance narrative probes and presentation coverage.
- Introduce new narrative probes for episode grammar, ensemble balance, and prose redundancy
- Implement narrative presentation coverage tracking for better event visibility
- Update harness scenarios to include new narrative ingestion and persistence tests
- Refactor event emission to include narrative development IDs and presentations
- Expand InterestCandidate to support narrative presentation models and roles
2026-07-23 14:12:23 -05:00

700 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using NeoModLoader.services;
using Newtonsoft.Json;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Versioned, fail-safe narrative sidecar. Runtime camera/episode state is intentionally excluded.
/// </summary>
public static class NarrativePersistence
{
public const int SchemaVersion = 1;
private const int MaxEvents = 2000;
private const int MaxDevelopments = 1600;
private const int MaxThreads = 500;
private const int MaxConsequences = 2400;
private const int MaxFacts = 4000;
private static readonly Dictionary<long, NarrativePreferenceDto> Preferences =
new Dictionary<long, NarrativePreferenceDto>();
private static bool _dirty;
private static bool _loading;
private static bool _writeSuppressed;
public static string LastStatus { get; private set; } = "";
public static string LastPath { get; private set; } = "";
public static bool Dirty => _dirty;
public static void MarkDirty()
{
if (!_loading) _dirty = true;
}
public static void ResetTransient()
{
Preferences.Clear();
_dirty = false;
_loading = false;
_writeSuppressed = false;
LastStatus = "";
LastPath = "";
}
public static void NotePreference(LifeSagaSlot slot)
{
if (slot == null || slot.UnitId == 0) return;
if (!slot.Prefer)
{
Preferences.Remove(slot.UnitId);
}
else
{
Preferences[slot.UnitId] = new NarrativePreferenceDto
{
characterId = slot.UnitId,
name = slot.DisplayName ?? "",
speciesId = slot.SpeciesId ?? ""
};
}
MarkDirty();
}
public static bool IsPreferred(long characterId, string name, string speciesId)
{
if (characterId == 0
|| !Preferences.TryGetValue(characterId, out NarrativePreferenceDto preference))
{
return false;
}
bool nameMatches = string.IsNullOrEmpty(preference.name)
|| string.IsNullOrEmpty(name)
|| string.Equals(preference.name, name, StringComparison.Ordinal);
bool speciesMatches = string.IsNullOrEmpty(preference.speciesId)
|| string.IsNullOrEmpty(speciesId)
|| string.Equals(
preference.speciesId, speciesId, StringComparison.OrdinalIgnoreCase);
return nameMatches && speciesMatches;
}
public static void SaveCommittedWorld()
{
if (_loading || _writeSuppressed || !_dirty || World.world == null)
{
return;
}
string worldKey = LifeSagaSession.ComputePersistentKey(World.world);
if (string.IsNullOrEmpty(worldKey))
{
LastStatus = "save_skipped_missing_world_key";
return;
}
try
{
NarrativeSaveState state = Capture(worldKey);
string json = JsonConvert.SerializeObject(state, Formatting.Indented);
string path = PathFor(worldKey);
WriteAtomic(path, json);
_dirty = false;
LastPath = path;
LastStatus = "saved events=" + state.events.Count
+ " developments=" + state.developments.Count
+ " facts=" + state.facts.Count;
LogService.LogInfo("[IdleSpectator] Narrative persistence " + LastStatus);
}
catch (Exception ex)
{
LastStatus = "save_failed:" + ex.GetType().Name;
LogService.LogWarning("[IdleSpectator] Narrative persistence " + LastStatus
+ " " + ex.Message);
}
}
public static bool LoadCurrentWorld()
{
ResetTransient();
if (World.world == null)
{
LastStatus = "load_skipped_missing_world";
return false;
}
string worldKey = LifeSagaSession.ComputePersistentKey(World.world);
string path = PathFor(worldKey);
LastPath = path;
if (!File.Exists(path))
{
LastStatus = "load_empty";
return false;
}
try
{
string json = File.ReadAllText(path);
NarrativeSaveState state = JsonConvert.DeserializeObject<NarrativeSaveState>(json);
if (state == null || state.schemaVersion <= 0)
{
PreserveUnreadable(path, "corrupt");
LastStatus = "load_corrupt";
return false;
}
if (state.schemaVersion > SchemaVersion)
{
_writeSuppressed = true;
LastStatus = "load_newer_schema:" + state.schemaVersion;
LogService.LogWarning(
"[IdleSpectator] Narrative sidecar uses newer schema; starting empty without overwrite");
return false;
}
if (!string.Equals(state.worldKey, worldKey, StringComparison.Ordinal))
{
LastStatus = "load_world_mismatch";
return false;
}
state.Unpack();
double currentWorldTime = CurrentWorldTime();
_loading = true;
Restore(state, currentWorldTime);
_loading = false;
_dirty = false;
LastStatus = "loaded events=" + NarrativeEventStore.Count
+ " developments=" + NarrativeStoryStore.DevelopmentCount
+ " lives=" + LifeSagaMemory.LifeCount;
LogService.LogInfo("[IdleSpectator] Narrative persistence " + LastStatus);
return true;
}
catch (Exception ex)
{
_loading = false;
PreserveUnreadable(path, "corrupt");
LastStatus = "load_failed:" + ex.GetType().Name;
LogService.LogWarning("[IdleSpectator] Narrative persistence " + LastStatus
+ " " + ex.Message);
return false;
}
}
public static bool HarnessRoundTrip(out string detail)
{
try
{
string key = "harness-world";
NarrativeSaveState state = Capture(key);
string json = JsonConvert.SerializeObject(state, Formatting.None);
NarrativeSaveState parsed = JsonConvert.DeserializeObject<NarrativeSaveState>(json);
parsed?.Unpack();
bool ok = parsed != null
&& parsed.schemaVersion == SchemaVersion
&& parsed.worldKey == key
&& parsed.events.Count == state.events.Count
&& parsed.developments.Count == state.developments.Count
&& parsed.facts.Count == state.facts.Count;
detail = "bytes=" + json.Length
+ " events=" + parsed?.events.Count
+ " developments=" + parsed?.developments.Count
+ " facts=" + parsed?.facts.Count
+ " pass=" + ok;
return ok;
}
catch (Exception ex)
{
detail = "exception=" + ex.GetType().Name + ":" + ex.Message;
return false;
}
}
public static bool HarnessRestoreRoundTrip(
Actor subject,
Actor other,
out string detail)
{
if (subject == null || other == null)
{
detail = "missing actors";
return false;
}
try
{
LifeSagaMemory.ClearSession();
NarrativeStoryStore.Clear();
NarrativeEventReceipt first =
LifeSagaMemory.RecordParentChild(subject, other, "harness_persistence");
NarrativeSaveState state = Capture("harness-world");
string json = JsonConvert.SerializeObject(state, Formatting.None);
NarrativeSaveState parsed = JsonConvert.DeserializeObject<NarrativeSaveState>(json);
parsed?.Unpack();
int expectedEvents = NarrativeEventStore.Count;
int expectedDevelopments = NarrativeStoryStore.DevelopmentCount;
int expectedConsequences = NarrativeStoryStore.CountConsequences(
EventFeedUtil.SafeId(subject), CharacterConsequenceKind.BecameParent);
LifeSagaMemory.ClearSession();
NarrativeStoryStore.Clear();
_loading = true;
Restore(parsed, parsed.savedWorldTime);
_loading = false;
NarrativeEventReceipt duplicate =
LifeSagaMemory.RecordParentChild(subject, other, "harness_persistence_replay");
int finalConsequences = NarrativeStoryStore.CountConsequences(
EventFeedUtil.SafeId(subject), CharacterConsequenceKind.BecameParent);
bool ok = first != null && first.IsNew
&& duplicate != null && !duplicate.IsNew
&& NarrativeEventStore.Count == expectedEvents
&& NarrativeStoryStore.DevelopmentCount == expectedDevelopments
&& expectedConsequences == 1
&& finalConsequences == expectedConsequences
&& LifeSagaMemory.HasFacts(EventFeedUtil.SafeId(subject));
detail = "json=" + json.Length
+ " packed=" + state.eventRecords.Length + "/"
+ state.developmentRecords.Length + "/" + state.factRecords.Length
+ " events=" + NarrativeEventStore.Count + "/" + expectedEvents
+ " developments=" + NarrativeStoryStore.DevelopmentCount
+ "/" + expectedDevelopments
+ " consequences=" + finalConsequences + "/" + expectedConsequences
+ " replayNew=" + (duplicate?.IsNew ?? true)
+ " pass=" + ok;
return ok;
}
catch (Exception ex)
{
_loading = false;
detail = "exception=" + ex.GetType().Name + ":" + ex.Message;
return false;
}
}
public static bool HarnessRejectsInvalid(out string detail)
{
NarrativeSaveState corrupt = null;
try
{
corrupt = JsonConvert.DeserializeObject<NarrativeSaveState>("{ definitely not json");
}
catch
{
// expected
}
NarrativeSaveState newer = JsonConvert.DeserializeObject<NarrativeSaveState>(
"{\"schemaVersion\":999,\"worldKey\":\"harness\"}");
bool corruptRejected = corrupt == null || corrupt.schemaVersion <= 0;
bool ok = corruptRejected && newer != null && newer.schemaVersion > SchemaVersion;
detail = "corruptRejected=" + corruptRejected
+ " newer=" + (newer?.schemaVersion ?? 0)
+ " pass=" + ok;
return ok;
}
private static NarrativeSaveState Capture(string worldKey)
{
var state = new NarrativeSaveState
{
schemaVersion = SchemaVersion,
worldKey = worldKey ?? "",
savedWorldTime = CurrentWorldTime(),
savedAtUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)
};
foreach (NarrativeEventRecord item in NarrativeEventStore.All())
state.events.Add(NarrativePersistenceMapper.ToDto(item));
foreach (NarrativeDevelopment item in NarrativeStoryStore.AllDevelopments())
state.developments.Add(NarrativePersistenceMapper.ToDto(item));
foreach (NarrativeThread item in NarrativeStoryStore.AllThreads())
state.threads.Add(NarrativePersistenceMapper.ToDto(item));
foreach (CharacterConsequence item in NarrativeStoryStore.AllConsequences())
state.consequences.Add(NarrativePersistenceMapper.ToDto(item));
foreach (CharacterStoryState item in NarrativeStoryStore.AllCharacters())
state.characters.Add(NarrativePersistenceMapper.ToDto(item));
foreach (LifeSagaFact item in LifeSagaMemory.AllFacts())
state.facts.Add(NarrativePersistenceMapper.ToDto(item));
foreach (NarrativePreferenceDto preference in Preferences.Values)
state.preferences.Add(preference);
Trim(state.events, MaxEvents, (a, b) => b.worldTime.CompareTo(a.worldTime));
Trim(state.developments, MaxDevelopments, (a, b) => b.occurredAt.CompareTo(a.occurredAt));
Trim(state.threads, MaxThreads, (a, b) => b.updatedAt.CompareTo(a.updatedAt));
Trim(state.consequences, MaxConsequences, (a, b) => b.occurredAt.CompareTo(a.occurredAt));
Trim(state.facts, MaxFacts, (a, b) => b.worldTime.CompareTo(a.worldTime));
state.Pack();
return state;
}
private static void Restore(NarrativeSaveState state, double currentWorldTime)
{
double cutoff = currentWorldTime > 0 ? currentWorldTime + 0.001d : double.MaxValue;
for (int i = 0; i < state.facts.Count; i++)
{
LifeSagaFactDto dto = state.facts[i];
if (dto != null && (dto.worldTime <= 0 || dto.worldTime <= cutoff))
LifeSagaMemory.ImportFact(NarrativePersistenceMapper.FromDto(dto));
}
for (int i = 0; i < state.events.Count; i++)
{
NarrativeEventRecordDto dto = state.events[i];
if (dto != null && (dto.worldTime <= 0 || dto.worldTime <= cutoff))
NarrativeEventStore.Import(NarrativePersistenceMapper.FromDto(dto));
}
for (int i = 0; i < state.developments.Count; i++)
{
NarrativeDevelopmentDto dto = state.developments[i];
if (dto == null || NarrativeEventStore.Get("event:" + (dto.id ?? "")) == null)
continue;
NarrativeStoryStore.ImportDevelopment(
NarrativePersistenceMapper.FromDto(dto, state.savedWorldTime, currentWorldTime));
}
for (int i = 0; i < state.threads.Count; i++)
{
NarrativeThreadDto dto = state.threads[i];
if (dto == null
|| NarrativeStoryStore.Development(dto.latestMeaningfulChangeId) == null)
continue;
NarrativeStoryStore.ImportThread(
NarrativePersistenceMapper.FromDto(dto, state.savedWorldTime, currentWorldTime));
}
for (int i = 0; i < state.consequences.Count; i++)
{
CharacterConsequenceDto dto = state.consequences[i];
if (dto == null || NarrativeStoryStore.Development(dto.developmentId) == null)
continue;
NarrativeStoryStore.ImportConsequence(
NarrativePersistenceMapper.FromDto(dto, state.savedWorldTime, currentWorldTime));
}
for (int i = 0; i < state.characters.Count; i++)
NarrativeStoryStore.ImportCharacter(
NarrativePersistenceMapper.FromDto(state.characters[i], state.savedWorldTime, currentWorldTime));
for (int i = 0; i < state.preferences.Count; i++)
{
NarrativePreferenceDto preference = state.preferences[i];
if (preference != null && preference.characterId != 0)
Preferences[preference.characterId] = preference;
}
NarrativeStoryStore.FinishImport();
LifeSagaRoster.Dirty = true;
}
private static float RebasedTime(float stored, double savedWorldTime, double currentWorldTime)
{
if (stored <= 0f) return Time.unscaledTime;
double worldDelta = currentWorldTime > 0 && savedWorldTime > 0
? Math.Max(0d, currentWorldTime - savedWorldTime)
: 0d;
return Time.unscaledTime - (float)Math.Min(86400d, worldDelta);
}
internal static float Rebase(float stored, double savedWorldTime, double currentWorldTime) =>
RebasedTime(stored, savedWorldTime, currentWorldTime);
private static double CurrentWorldTime()
{
try
{
return World.world != null ? World.world.getCurWorldTime() : 0d;
}
catch
{
return 0d;
}
}
private static string PathFor(string worldKey)
{
string root = Path.Combine(Application.persistentDataPath, "IdleSpectator", "narrative");
Directory.CreateDirectory(root);
return Path.Combine(root, StableHash(worldKey) + ".json");
}
private static string StableHash(string value)
{
unchecked
{
ulong hash = 1469598103934665603UL;
string text = value ?? "";
for (int i = 0; i < text.Length; i++)
{
hash ^= text[i];
hash *= 1099511628211UL;
}
return hash.ToString("x16", CultureInfo.InvariantCulture);
}
}
private static void WriteAtomic(string path, string json)
{
string temp = path + ".tmp";
string backup = path + ".bak";
File.WriteAllText(temp, json ?? "");
if (File.Exists(path))
{
try
{
File.Replace(temp, path, backup);
return;
}
catch
{
File.Copy(path, backup, true);
File.Delete(path);
}
}
File.Move(temp, path);
}
private static void PreserveUnreadable(string path, string suffix)
{
try
{
if (!File.Exists(path)) return;
string preserved = path + "." + suffix + "." + DateTime.UtcNow.Ticks;
File.Move(path, preserved);
}
catch
{
// Failure recovery must never block a world load.
}
}
private static void Trim<T>(List<T> items, int max, Comparison<T> comparison)
{
items.Sort(comparison);
if (items.Count > max) items.RemoveRange(max, items.Count - max);
}
}
[Serializable]
public sealed class NarrativeSaveState
{
public int schemaVersion;
public string worldKey = "";
public double savedWorldTime;
public string savedAtUtc = "";
public NarrativeEventRecordDto[] eventRecords = new NarrativeEventRecordDto[0];
public NarrativeDevelopmentDto[] developmentRecords = new NarrativeDevelopmentDto[0];
public NarrativeThreadDto[] threadRecords = new NarrativeThreadDto[0];
public CharacterConsequenceDto[] consequenceRecords = new CharacterConsequenceDto[0];
public CharacterStoryStateDto[] characterRecords = new CharacterStoryStateDto[0];
public LifeSagaFactDto[] factRecords = new LifeSagaFactDto[0];
public NarrativePreferenceDto[] preferenceRecords = new NarrativePreferenceDto[0];
[NonSerialized] public List<NarrativeEventRecordDto> events =
new List<NarrativeEventRecordDto>();
[NonSerialized] public List<NarrativeDevelopmentDto> developments =
new List<NarrativeDevelopmentDto>();
[NonSerialized] public List<NarrativeThreadDto> threads = new List<NarrativeThreadDto>();
[NonSerialized] public List<CharacterConsequenceDto> consequences =
new List<CharacterConsequenceDto>();
[NonSerialized] public List<CharacterStoryStateDto> characters =
new List<CharacterStoryStateDto>();
[NonSerialized] public List<LifeSagaFactDto> facts = new List<LifeSagaFactDto>();
[NonSerialized] public List<NarrativePreferenceDto> preferences =
new List<NarrativePreferenceDto>();
public void Pack()
{
eventRecords = events.ToArray();
developmentRecords = developments.ToArray();
threadRecords = threads.ToArray();
consequenceRecords = consequences.ToArray();
characterRecords = characters.ToArray();
factRecords = facts.ToArray();
preferenceRecords = preferences.ToArray();
}
public void Unpack()
{
events = new List<NarrativeEventRecordDto>(
eventRecords ?? new NarrativeEventRecordDto[0]);
developments = new List<NarrativeDevelopmentDto>(
developmentRecords ?? new NarrativeDevelopmentDto[0]);
threads = new List<NarrativeThreadDto>(
threadRecords ?? new NarrativeThreadDto[0]);
consequences = new List<CharacterConsequenceDto>(
consequenceRecords ?? new CharacterConsequenceDto[0]);
characters = new List<CharacterStoryStateDto>(
characterRecords ?? new CharacterStoryStateDto[0]);
facts = new List<LifeSagaFactDto>(factRecords ?? new LifeSagaFactDto[0]);
preferences = new List<NarrativePreferenceDto>(
preferenceRecords ?? new NarrativePreferenceDto[0]);
}
}
[Serializable]
public sealed class NarrativeIdentityDto
{
public long id;
public string name = "";
public string speciesId = "";
}
[Serializable]
public sealed class NarrativeEventRecordDto
{
public string id = "";
public string dedupeKey = "";
public int kind;
public long subjectId;
public NarrativeIdentityDto subject = new NarrativeIdentityDto();
public long otherId;
public NarrativeIdentityDto other = new NarrativeIdentityDto();
public string developmentId = "";
public string correlationKey = "";
public string familyKey = "";
public string cityKey = "";
public string kingdomKey = "";
public string warKey = "";
public string plotKey = "";
public string previousState = "";
public string newState = "";
public string outcome = "";
public string note = "";
public bool resolved;
public string evidenceSource = "";
public string dateLabel = "";
public double worldTime;
public float observedAt;
public float magnitude;
public float confidence;
}
[Serializable]
public sealed class NarrativeDevelopmentDto
{
public string id = "";
public int kind;
public int function;
public long subjectId;
public NarrativeIdentityDto subject = new NarrativeIdentityDto();
public long otherId;
public NarrativeIdentityDto other = new NarrativeIdentityDto();
public long[] otherIds = new long[0];
public string correlationKey = "";
public string familyKey = "";
public string cityKey = "";
public string kingdomKey = "";
public string warKey = "";
public string plotKey = "";
public string previousState = "";
public string newState = "";
public string outcome = "";
public string evidenceSource = "";
public string dateLabel = "";
public float occurredAt;
public float magnitude;
public float confidence;
public bool presentable;
public bool isNewStateChange;
}
[Serializable]
public sealed class NarrativeCastRoleDto
{
public long characterId;
public string role = "";
}
[Serializable]
public sealed class NarrativeThreadDto
{
public string id = "";
public int kind;
public long protagonistId;
public NarrativeCastRoleDto[] cast = new NarrativeCastRoleDto[0];
public string[] developmentIds = new string[0];
public string anchorKey = "";
public string openedByDevelopmentId = "";
public string latestMeaningfulChangeId = "";
public string centralQuestion = "";
public string pressureEvidence = "";
public string resolution = "";
public int phase;
public int status;
public float openedAt;
public float updatedAt;
public float momentum;
public float urgency;
public float coherence;
public float novelty;
public float coverageDebt;
}
[Serializable]
public sealed class CharacterConsequenceDto
{
public string id = "";
public long characterId;
public string threadId = "";
public string developmentId = "";
public int kind;
public string role = "";
public long otherId;
public NarrativeIdentityDto other = new NarrativeIdentityDto();
public string context = "";
public string outcome = "";
public string dateLabel = "";
public float occurredAt;
public float importance;
public float confidence;
}
[Serializable]
public sealed class CharacterStoryStateDto
{
public long characterId;
public NarrativeIdentityDto identity = new NarrativeIdentityDto();
public string[] openThreadIds = new string[0];
public string[] resolvedThreadIds = new string[0];
public string[] recentDevelopmentIds = new string[0];
public string[] consequenceIds = new string[0];
public float storyMomentum;
public float storyPotential;
public float lastMeaningfulChangeAt;
}
[Serializable]
public sealed class LifeSagaFactDto
{
public string key = "";
public int kind;
public long subjectId;
public long otherId;
public NarrativeIdentityDto subject = new NarrativeIdentityDto();
public NarrativeIdentityDto other = new NarrativeIdentityDto();
public float at;
public double worldTime;
public string dateLabel = "";
public float strength;
public string provenance = "";
public string kingdomKey = "";
public string cityKey = "";
public string clanKey = "";
public string familyKey = "";
public string warKey = "";
public string plotKey = "";
public bool resolved;
public string note = "";
public bool mcCrossover;
}
[Serializable]
public sealed class NarrativePreferenceDto
{
public long characterId;
public string name = "";
public string speciesId = "";
}