- Implement checks for civilized terminology in camera focus events - Update event handling to suppress inappropriate pack language for civilized characters - Enhance narrative presentation to differentiate between family and pack language - Introduce new scenarios to validate significance of beats and relationships - Improve documentation to clarify narrative structure and event significance
1003 lines
36 KiB
C#
1003 lines
36 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 const int MaxCharacters = 1600;
|
|
|
|
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);
|
|
// Sidecars are machine-owned. Compact JSON cuts write size substantially without
|
|
// discarding a single event, development, thread, consequence, character, or fact.
|
|
string json = JsonConvert.SerializeObject(state, Formatting.None);
|
|
string path = PathFor(worldKey);
|
|
WriteAtomic(path, json);
|
|
_dirty = false;
|
|
LastPath = path;
|
|
LastStatus = "saved events=" + state.events.Count
|
|
+ " developments=" + state.developments.Count
|
|
+ " threads=" + state.threads.Count
|
|
+ " characters=" + state.characters.Count
|
|
+ " facts=" + state.facts.Count
|
|
+ " bytes=" + json.Length;
|
|
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));
|
|
CompactGraph(state, MaxCharacters);
|
|
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.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trim the sidecar as a graph: retained threads, consequences, and character
|
|
/// references must point at retained developments. Active casts and player-selected
|
|
/// lives are protected from character compaction.
|
|
/// </summary>
|
|
private static void CompactGraph(
|
|
NarrativeSaveState state,
|
|
int characterLimit,
|
|
bool protectRuntimeRoster = true)
|
|
{
|
|
if (state == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var eventDevelopmentIds = new HashSet<string>(StringComparer.Ordinal);
|
|
for (int i = 0; i < state.events.Count; i++)
|
|
{
|
|
NarrativeEventRecordDto item = state.events[i];
|
|
if (item == null) continue;
|
|
if (!string.IsNullOrEmpty(item.developmentId))
|
|
{
|
|
eventDevelopmentIds.Add(item.developmentId);
|
|
}
|
|
else if (!string.IsNullOrEmpty(item.id)
|
|
&& item.id.StartsWith("event:", StringComparison.Ordinal))
|
|
{
|
|
eventDevelopmentIds.Add(item.id.Substring("event:".Length));
|
|
}
|
|
}
|
|
|
|
if (eventDevelopmentIds.Count > 0)
|
|
{
|
|
state.developments.RemoveAll(
|
|
item => item == null
|
|
|| string.IsNullOrEmpty(item.id)
|
|
|| !eventDevelopmentIds.Contains(item.id));
|
|
}
|
|
|
|
var developments = new HashSet<string>(StringComparer.Ordinal);
|
|
for (int i = 0; i < state.developments.Count; i++)
|
|
{
|
|
NarrativeDevelopmentDto item = state.developments[i];
|
|
if (item != null && !string.IsNullOrEmpty(item.id))
|
|
{
|
|
developments.Add(item.id);
|
|
}
|
|
}
|
|
|
|
for (int i = state.threads.Count - 1; i >= 0; i--)
|
|
{
|
|
NarrativeThreadDto thread = state.threads[i];
|
|
if (thread == null)
|
|
{
|
|
state.threads.RemoveAt(i);
|
|
continue;
|
|
}
|
|
|
|
thread.developmentIds = Filter(thread.developmentIds, developments);
|
|
if (!developments.Contains(thread.latestMeaningfulChangeId))
|
|
{
|
|
thread.latestMeaningfulChangeId = thread.developmentIds.Length > 0
|
|
? thread.developmentIds[thread.developmentIds.Length - 1]
|
|
: "";
|
|
}
|
|
if (!developments.Contains(thread.openedByDevelopmentId))
|
|
{
|
|
thread.openedByDevelopmentId = thread.developmentIds.Length > 0
|
|
? thread.developmentIds[0]
|
|
: thread.latestMeaningfulChangeId;
|
|
}
|
|
if (string.IsNullOrEmpty(thread.latestMeaningfulChangeId))
|
|
{
|
|
state.threads.RemoveAt(i);
|
|
}
|
|
}
|
|
|
|
var threads = new HashSet<string>(StringComparer.Ordinal);
|
|
for (int i = 0; i < state.threads.Count; i++)
|
|
{
|
|
NarrativeThreadDto item = state.threads[i];
|
|
if (item != null && !string.IsNullOrEmpty(item.id))
|
|
{
|
|
threads.Add(item.id);
|
|
}
|
|
}
|
|
|
|
state.consequences.RemoveAll(
|
|
item => item == null
|
|
|| string.IsNullOrEmpty(item.id)
|
|
|| !developments.Contains(item.developmentId));
|
|
var consequences = new HashSet<string>(StringComparer.Ordinal);
|
|
for (int i = 0; i < state.consequences.Count; i++)
|
|
{
|
|
CharacterConsequenceDto item = state.consequences[i];
|
|
if (!string.IsNullOrEmpty(item.threadId) && !threads.Contains(item.threadId))
|
|
{
|
|
item.threadId = "";
|
|
}
|
|
consequences.Add(item.id);
|
|
}
|
|
|
|
var protectedCharacters = new HashSet<long>();
|
|
for (int i = 0; i < state.preferences.Count; i++)
|
|
{
|
|
NarrativePreferenceDto preference = state.preferences[i];
|
|
if (preference != null && preference.characterId != 0)
|
|
{
|
|
protectedCharacters.Add(preference.characterId);
|
|
}
|
|
}
|
|
for (int i = 0; i < state.threads.Count; i++)
|
|
{
|
|
NarrativeThreadDto thread = state.threads[i];
|
|
if (thread == null
|
|
|| thread.status > (int)NarrativeThreadStatus.Dormant)
|
|
{
|
|
continue;
|
|
}
|
|
if (thread.protagonistId != 0)
|
|
{
|
|
protectedCharacters.Add(thread.protagonistId);
|
|
}
|
|
NarrativeCastRoleDto[] cast = thread.cast ?? new NarrativeCastRoleDto[0];
|
|
for (int j = 0; j < cast.Length; j++)
|
|
{
|
|
if (cast[j] != null && cast[j].characterId != 0)
|
|
{
|
|
protectedCharacters.Add(cast[j].characterId);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < state.characters.Count; i++)
|
|
{
|
|
CharacterStoryStateDto character = state.characters[i];
|
|
if (character == null) continue;
|
|
if (protectRuntimeRoster
|
|
&& (LifeSagaRoster.IsMc(character.characterId)
|
|
|| LifeSagaRoster.IsPrefer(character.characterId)))
|
|
{
|
|
protectedCharacters.Add(character.characterId);
|
|
}
|
|
character.openThreadIds = Filter(character.openThreadIds, threads);
|
|
character.resolvedThreadIds = Filter(character.resolvedThreadIds, threads);
|
|
character.recentDevelopmentIds = Filter(
|
|
character.recentDevelopmentIds,
|
|
developments);
|
|
character.consequenceIds = Filter(character.consequenceIds, consequences);
|
|
character.storyPotential = character.storyMomentum
|
|
+ character.openThreadIds.Length * 2.5f
|
|
+ Mathf.Min(6f, character.consequenceIds.Length * 0.8f);
|
|
}
|
|
|
|
state.characters.RemoveAll(item => item == null || item.characterId == 0);
|
|
state.characters.Sort((a, b) =>
|
|
{
|
|
bool aProtected = protectedCharacters.Contains(a.characterId);
|
|
bool bProtected = protectedCharacters.Contains(b.characterId);
|
|
if (aProtected != bProtected) return aProtected ? -1 : 1;
|
|
int potential = b.storyPotential.CompareTo(a.storyPotential);
|
|
if (potential != 0) return potential;
|
|
return b.lastMeaningfulChangeAt.CompareTo(a.lastMeaningfulChangeAt);
|
|
});
|
|
int protectedCount = 0;
|
|
for (int i = 0; i < state.characters.Count; i++)
|
|
{
|
|
if (protectedCharacters.Contains(state.characters[i].characterId))
|
|
{
|
|
protectedCount++;
|
|
}
|
|
}
|
|
int keep = Math.Max(Math.Max(0, characterLimit), protectedCount);
|
|
if (state.characters.Count > keep)
|
|
{
|
|
state.characters.RemoveRange(keep, state.characters.Count - keep);
|
|
}
|
|
}
|
|
|
|
private static string[] Filter(string[] values, HashSet<string> retained)
|
|
{
|
|
if (values == null || values.Length == 0 || retained == null)
|
|
{
|
|
return new string[0];
|
|
}
|
|
|
|
var result = new List<string>(values.Length);
|
|
for (int i = 0; i < values.Length; i++)
|
|
{
|
|
string value = values[i];
|
|
if (!string.IsNullOrEmpty(value)
|
|
&& retained.Contains(value)
|
|
&& !result.Contains(value))
|
|
{
|
|
result.Add(value);
|
|
}
|
|
}
|
|
|
|
return result.ToArray();
|
|
}
|
|
|
|
public static bool HarnessProbeGraphCompaction(out string detail)
|
|
{
|
|
var state = new NarrativeSaveState();
|
|
state.events.Add(new NarrativeEventRecordDto
|
|
{
|
|
id = "event:dev:kept",
|
|
developmentId = "dev:kept"
|
|
});
|
|
state.developments.Add(new NarrativeDevelopmentDto
|
|
{
|
|
id = "dev:kept",
|
|
subjectId = 10,
|
|
occurredAt = 2f
|
|
});
|
|
state.developments.Add(new NarrativeDevelopmentDto
|
|
{
|
|
id = "dev:orphan",
|
|
subjectId = 30,
|
|
occurredAt = 3f
|
|
});
|
|
state.threads.Add(new NarrativeThreadDto
|
|
{
|
|
id = "thread:kept",
|
|
protagonistId = 10,
|
|
cast = new[]
|
|
{
|
|
new NarrativeCastRoleDto { characterId = 10, role = "protagonist" },
|
|
new NarrativeCastRoleDto { characterId = 20, role = "partner" }
|
|
},
|
|
developmentIds = new[] { "dev:kept", "dev:orphan" },
|
|
latestMeaningfulChangeId = "dev:kept",
|
|
status = (int)NarrativeThreadStatus.Active
|
|
});
|
|
state.threads.Add(new NarrativeThreadDto
|
|
{
|
|
id = "thread:orphan",
|
|
protagonistId = 30,
|
|
developmentIds = new[] { "dev:orphan" },
|
|
latestMeaningfulChangeId = "dev:orphan",
|
|
status = (int)NarrativeThreadStatus.Active
|
|
});
|
|
state.consequences.Add(new CharacterConsequenceDto
|
|
{
|
|
id = "consequence:kept",
|
|
characterId = 10,
|
|
threadId = "thread:kept",
|
|
developmentId = "dev:kept"
|
|
});
|
|
state.consequences.Add(new CharacterConsequenceDto
|
|
{
|
|
id = "consequence:orphan",
|
|
characterId = 30,
|
|
threadId = "thread:orphan",
|
|
developmentId = "dev:orphan"
|
|
});
|
|
state.characters.Add(new CharacterStoryStateDto
|
|
{
|
|
characterId = 10,
|
|
openThreadIds = new[] { "thread:kept", "thread:orphan" },
|
|
recentDevelopmentIds = new[] { "dev:kept", "dev:orphan" },
|
|
consequenceIds = new[] { "consequence:kept", "consequence:orphan" },
|
|
storyMomentum = 2f,
|
|
storyPotential = 99f
|
|
});
|
|
state.characters.Add(new CharacterStoryStateDto { characterId = 20 });
|
|
state.characters.Add(new CharacterStoryStateDto
|
|
{
|
|
characterId = 30,
|
|
storyPotential = 200f
|
|
});
|
|
|
|
// This probe owns a synthetic graph and must not inherit protection from
|
|
// whichever live-world actors happen to share its deliberately small IDs.
|
|
CompactGraph(state, 2, protectRuntimeRoster: false);
|
|
CharacterStoryStateDto protagonist =
|
|
state.characters.Find(item => item.characterId == 10);
|
|
bool ok = state.developments.Count == 1
|
|
&& state.threads.Count == 1
|
|
&& state.consequences.Count == 1
|
|
&& state.characters.Count == 2
|
|
&& state.characters.Exists(item => item.characterId == 20)
|
|
&& !state.characters.Exists(item => item.characterId == 30)
|
|
&& protagonist != null
|
|
&& protagonist.openThreadIds.Length == 1
|
|
&& protagonist.recentDevelopmentIds.Length == 1
|
|
&& protagonist.consequenceIds.Length == 1
|
|
&& protagonist.storyPotential < 20f;
|
|
detail = "graph=" + state.developments.Count + "/"
|
|
+ state.threads.Count + "/" + state.consequences.Count
|
|
+ " characters=" + state.characters.Count
|
|
+ " potential=" + (protagonist?.storyPotential.ToString("0.0") ?? "none")
|
|
+ " pass=" + ok;
|
|
return ok;
|
|
}
|
|
|
|
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 = "";
|
|
}
|