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
This commit is contained in:
parent
739cc6492c
commit
ff4b40d00e
38 changed files with 5121 additions and 735 deletions
|
|
@ -13,7 +13,6 @@ public static class ActorChroniclePatches
|
|||
public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
|
||||
{
|
||||
LifeSagaMemory.RecordKill(__instance, pDeadUnit);
|
||||
NarrativeDevelopmentRouter.Kill(__instance, pDeadUnit, "newKillAction");
|
||||
Chronicle.NoteKill(__instance, pDeadUnit);
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +53,6 @@ public static class ActorChroniclePatches
|
|||
}
|
||||
|
||||
LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString());
|
||||
NarrativeDevelopmentRouter.Death(__instance, __state, pType.ToString(), "die");
|
||||
Chronicle.NoteDeath(__instance, pType, __state);
|
||||
GraveMarkers.AddDeath(__instance);
|
||||
}
|
||||
|
|
@ -64,7 +62,6 @@ public static class ActorChroniclePatches
|
|||
public static void PostfixLovers(Actor __instance, Actor pTarget)
|
||||
{
|
||||
LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith");
|
||||
NarrativeDevelopmentRouter.BondFormed(__instance, pTarget, "becomeLoversWith");
|
||||
Chronicle.NoteLovers(__instance, pTarget);
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +75,6 @@ public static class ActorChroniclePatches
|
|||
}
|
||||
|
||||
LifeSagaMemory.RecordFriendFormed(__instance, pActor);
|
||||
NarrativeDevelopmentRouter.FriendFormed(__instance, pActor, "setBestFriend");
|
||||
Chronicle.NoteBestFriends(__instance, pActor);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2914,6 +2914,270 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "narrative_episode_grammar_probe":
|
||||
{
|
||||
bool ok = StoryScheduler.HarnessProbeGrammar(out string detail);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "narrative_ensemble_balance_probe":
|
||||
{
|
||||
bool ok = NarrativeExposureLedger.HarnessProbeBalance(out string detail);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "narrative_prose_redundancy_probe":
|
||||
{
|
||||
bool ok = NarrativeRenderer.HarnessProbeRelationshipProse(out string detail);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: detail + " coverage=" + NarrativePresentationCoverage.Summary);
|
||||
break;
|
||||
}
|
||||
|
||||
case "narrative_presentation_coverage_probe":
|
||||
{
|
||||
Actor subject = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
||||
bool ok = EventFeedUtil.HarnessProbePresentationCoverage(subject, out string detail);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "narrative_relationship_ingestion_probe":
|
||||
{
|
||||
bool ok = false;
|
||||
string detail = "";
|
||||
try
|
||||
{
|
||||
Actor parent = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
||||
var others = new List<Actor>(2);
|
||||
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||
{
|
||||
if (actor != null && actor.isAlive() && actor != parent && !others.Contains(actor))
|
||||
{
|
||||
others.Add(actor);
|
||||
}
|
||||
|
||||
if (others.Count >= 2) break;
|
||||
}
|
||||
|
||||
if (parent == null || others.Count < 2)
|
||||
{
|
||||
detail = "missing actors parent=" + (parent != null) + " others=" + others.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
Actor firstChild = others[0];
|
||||
Actor secondChild = others[1];
|
||||
LifeSagaMemory.ClearSession();
|
||||
NarrativeStoryStore.Clear();
|
||||
CaptionComposer.Clear();
|
||||
NarrativePresentationCoverage.Clear();
|
||||
|
||||
NarrativeEventReceipt first =
|
||||
LifeSagaMemory.RecordParentChild(parent, firstChild, "harness_ingestion");
|
||||
NarrativeEventReceipt duplicate =
|
||||
LifeSagaMemory.RecordParentChild(parent, firstChild, "harness_ingestion_duplicate");
|
||||
int eventCountAfterDuplicate = NarrativeEventStore.Count;
|
||||
int developmentCountAfterDuplicate = NarrativeStoryStore.DevelopmentCount;
|
||||
int consequenceCountAfterDuplicate = NarrativeStoryStore.CountConsequences(
|
||||
EventFeedUtil.SafeId(parent), CharacterConsequenceKind.BecameParent);
|
||||
|
||||
NarrativeEventReceipt second =
|
||||
LifeSagaMemory.RecordParentChild(parent, secondChild, "harness_ingestion_second");
|
||||
var tip = new InterestCandidate
|
||||
{
|
||||
Key = "harness:narrative:exact-child",
|
||||
Source = "harness",
|
||||
Category = "Family",
|
||||
Label = EventReason.NewChild(parent, firstChild),
|
||||
AssetId = "add_child",
|
||||
FollowUnit = parent,
|
||||
RelatedUnit = firstChild,
|
||||
SubjectId = EventFeedUtil.SafeId(parent),
|
||||
RelatedId = EventFeedUtil.SafeId(firstChild),
|
||||
NarrativeDevelopmentId = first.DevelopmentId,
|
||||
NarrativePresentation = first.Presentation?.Clone()
|
||||
};
|
||||
CaptionModel caption = CaptionComposer.Compose(
|
||||
parent,
|
||||
tip,
|
||||
tip.Label,
|
||||
tip.RelatedId,
|
||||
"",
|
||||
statusBannerOwnsBeat: false);
|
||||
string firstName = EventFeedUtil.SafeName(firstChild);
|
||||
string secondName = EventFeedUtil.SafeName(secondChild);
|
||||
bool exactFirst = !string.IsNullOrEmpty(firstName)
|
||||
&& caption.BeatLine.IndexOf(
|
||||
firstName, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
&& (string.IsNullOrEmpty(secondName)
|
||||
|| caption.BeatLine.IndexOf(
|
||||
secondName, StringComparison.OrdinalIgnoreCase) < 0);
|
||||
ok = first != null && first.IsNew
|
||||
&& duplicate != null && !duplicate.IsNew
|
||||
&& second != null && second.IsNew
|
||||
&& eventCountAfterDuplicate == 1
|
||||
&& developmentCountAfterDuplicate == 1
|
||||
&& consequenceCountAfterDuplicate == 1
|
||||
&& NarrativeEventStore.Count == 2
|
||||
&& NarrativeStoryStore.DevelopmentCount == 2
|
||||
&& exactFirst
|
||||
&& caption.ReasonRelatedId == EventFeedUtil.SafeId(firstChild)
|
||||
&& !NarrativeRenderer.HasSemanticRedundancy(
|
||||
caption.BeatLine, first.Presentation)
|
||||
&& NarrativePresentationCoverage.ExactDevelopmentCount == 1;
|
||||
detail = "first=" + first?.EventId
|
||||
+ " duplicateNew=" + (duplicate?.IsNew ?? true)
|
||||
+ " second=" + second?.EventId
|
||||
+ " counts=" + eventCountAfterDuplicate + "/"
|
||||
+ developmentCountAfterDuplicate + "/"
|
||||
+ consequenceCountAfterDuplicate
|
||||
+ " final=" + NarrativeEventStore.Count + "/"
|
||||
+ NarrativeStoryStore.DevelopmentCount
|
||||
+ " beat='" + caption.BeatLine + "'"
|
||||
+ " related=" + caption.ReasonRelatedId
|
||||
+ " coverage=" + NarrativePresentationCoverage.Summary;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
detail = "exception=" + ex.GetType().Name + ":" + ex.Message;
|
||||
}
|
||||
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "narrative_ingestion_matrix_probe":
|
||||
{
|
||||
bool ok = false;
|
||||
string detail = "";
|
||||
try
|
||||
{
|
||||
Actor subject = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
||||
Actor other = FindOtherAliveUnit(subject, "human");
|
||||
if (subject == null || other == null)
|
||||
{
|
||||
detail = "missing actors subject=" + (subject != null) + " other=" + (other != null);
|
||||
}
|
||||
else
|
||||
{
|
||||
LifeSagaMemory.ClearSession();
|
||||
NarrativeStoryStore.Clear();
|
||||
var firsts = new List<NarrativeEventReceipt>
|
||||
{
|
||||
LifeSagaMemory.RecordKill(subject, other, "harness_matrix"),
|
||||
LifeSagaMemory.RecordDeath(other, subject, "Harness", "harness_matrix"),
|
||||
LifeSagaMemory.RecordRole(subject, "harness_champion", "harness_matrix"),
|
||||
LifeSagaMemory.RecordHome(subject, true, "harness_matrix"),
|
||||
LifeSagaMemory.RecordHome(subject, false, "harness_matrix"),
|
||||
LifeSagaMemory.RecordFounding(
|
||||
subject, "city", "harness_city", "harness_matrix"),
|
||||
LifeSagaMemory.RecordWar(
|
||||
subject, "harness_war", "harness_kingdom", false,
|
||||
"harness_matrix", other),
|
||||
LifeSagaMemory.RecordWar(
|
||||
subject, "harness_war", "harness_kingdom", true,
|
||||
"harness_matrix", other),
|
||||
LifeSagaMemory.RecordPlot(
|
||||
subject, "harness_plot", "harness_plot_asset", false,
|
||||
"harness_matrix", other),
|
||||
LifeSagaMemory.RecordPlot(
|
||||
subject, "harness_plot", "harness_plot_asset", true,
|
||||
"harness_matrix", other)
|
||||
};
|
||||
var duplicates = new List<NarrativeEventReceipt>
|
||||
{
|
||||
LifeSagaMemory.RecordKill(subject, other, "harness_matrix_duplicate"),
|
||||
LifeSagaMemory.RecordDeath(
|
||||
other, subject, "Harness", "harness_matrix_duplicate"),
|
||||
LifeSagaMemory.RecordRole(
|
||||
subject, "harness_champion", "harness_matrix_duplicate"),
|
||||
LifeSagaMemory.RecordHome(subject, true, "harness_matrix_duplicate"),
|
||||
LifeSagaMemory.RecordHome(subject, false, "harness_matrix_duplicate"),
|
||||
LifeSagaMemory.RecordFounding(
|
||||
subject, "city", "harness_city", "harness_matrix_duplicate"),
|
||||
LifeSagaMemory.RecordWar(
|
||||
subject, "harness_war", "harness_kingdom", false,
|
||||
"harness_matrix_duplicate", other),
|
||||
LifeSagaMemory.RecordWar(
|
||||
subject, "harness_war", "harness_kingdom", true,
|
||||
"harness_matrix_duplicate", other),
|
||||
LifeSagaMemory.RecordPlot(
|
||||
subject, "harness_plot", "harness_plot_asset", false,
|
||||
"harness_matrix_duplicate", other),
|
||||
LifeSagaMemory.RecordPlot(
|
||||
subject, "harness_plot", "harness_plot_asset", true,
|
||||
"harness_matrix_duplicate", other)
|
||||
};
|
||||
|
||||
bool firstsNew = true;
|
||||
bool duplicatesOld = true;
|
||||
bool exactLinks = true;
|
||||
for (int i = 0; i < firsts.Count; i++)
|
||||
{
|
||||
NarrativeEventReceipt first = firsts[i];
|
||||
NarrativeEventReceipt duplicate = duplicates[i];
|
||||
firstsNew &= first != null && first.IsNew;
|
||||
duplicatesOld &= duplicate != null && !duplicate.IsNew;
|
||||
exactLinks &= first != null
|
||||
&& !string.IsNullOrEmpty(first.EventId)
|
||||
&& !string.IsNullOrEmpty(first.DevelopmentId)
|
||||
&& NarrativeEventStore.Get(first.EventId) != null
|
||||
&& NarrativeStoryStore.Development(first.DevelopmentId) != null
|
||||
&& duplicate != null
|
||||
&& duplicate.EventId == first.EventId
|
||||
&& duplicate.DevelopmentId == first.DevelopmentId;
|
||||
}
|
||||
|
||||
ok = firstsNew
|
||||
&& duplicatesOld
|
||||
&& exactLinks
|
||||
&& NarrativeEventStore.Count == firsts.Count
|
||||
&& NarrativeStoryStore.DevelopmentCount == firsts.Count;
|
||||
detail = "new=" + firstsNew
|
||||
+ " duplicateOld=" + duplicatesOld
|
||||
+ " exact=" + exactLinks
|
||||
+ " events=" + NarrativeEventStore.Count
|
||||
+ " developments=" + NarrativeStoryStore.DevelopmentCount
|
||||
+ " expected=" + firsts.Count;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
detail = "exception=" + ex.GetType().Name + ":" + ex.Message;
|
||||
}
|
||||
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "narrative_persistence_probe":
|
||||
{
|
||||
Actor subject = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
||||
Actor other = FindOtherAliveUnit(subject, "human");
|
||||
bool ok = NarrativePersistence.HarnessRestoreRoundTrip(
|
||||
subject, other, out string detail);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "narrative_persistence_invalid_probe":
|
||||
{
|
||||
bool ok = NarrativePersistence.HarnessRejectsInvalid(out string detail);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "narrative_camera_cutover_probe":
|
||||
{
|
||||
bool ok = false;
|
||||
|
|
@ -10591,11 +10855,26 @@ public static class AgentHarness
|
|||
}
|
||||
else
|
||||
{
|
||||
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|
||||
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0
|
||||
long focusId = EventFeedUtil.SafeId(unit);
|
||||
InterestCandidate current = InterestDirector.CurrentCandidate;
|
||||
bool candidateOwnsFocus = current != null
|
||||
&& (current.SubjectId == focusId
|
||||
|| EventFeedUtil.SafeId(current.FollowUnit) == focusId);
|
||||
bool discoveryOwnsFocus = tip.StartsWith(
|
||||
"New species: ",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(
|
||||
CameraDirector.LastWatchAssetId,
|
||||
unit?.asset?.id,
|
||||
StringComparison.Ordinal);
|
||||
pass = candidateOwnsFocus
|
||||
|| discoveryOwnsFocus
|
||||
|| tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|
||||
|| IsEnsembleCombatTip(tip);
|
||||
// Ensemble tips are tier-led (Battle - …); focus still owns the dossier.
|
||||
detail = $"tip='{tip}' focus='{name}' pass={pass}";
|
||||
// Candidate identity is authoritative. The rendered reason may deliberately
|
||||
// omit the subject name, especially for discoveries and semantic Beats.
|
||||
detail = $"tip='{tip}' focus='{name}' candidateOwns={candidateOwnsFocus}"
|
||||
+ $" discoveryOwns={discoveryOwnsFocus} pass={pass}";
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -237,7 +237,10 @@ public static class EventLiveRelationshipHarness
|
|||
AgeOutOfSpawnWindow(lonely);
|
||||
try
|
||||
{
|
||||
// Park far from founder family if possible (still same world).
|
||||
// This is a negative outcome probe, so actually isolate the actor from the
|
||||
// founder family. SpawnHumanNear(a) intentionally puts it inside the positive
|
||||
// probe's search area and can otherwise produce a legitimate join.
|
||||
TryIsolateFromPeers(lonely);
|
||||
InterestDropLog.Clear();
|
||||
InterestRegistry.ForceExpireContaining("rel:family_group_join", Time.unscaledTime);
|
||||
int keysBefore = InterestRegistry.CountKeysContaining("rel:family_group_join");
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ public static class EventFeedUtil
|
|||
bool locationOnly = false,
|
||||
float minWatch = 4f,
|
||||
float maxWatch = 22f,
|
||||
InterestCompletionKind completion = InterestCompletionKind.FixedDwell)
|
||||
InterestCompletionKind completion = InterestCompletionKind.FixedDwell,
|
||||
NarrativePresentationModel presentation = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
|
|
@ -90,6 +91,8 @@ public static class EventFeedUtil
|
|||
RelatedUnit = relatedAlive,
|
||||
SubjectId = subject != null ? SafeId(subject) : 0,
|
||||
RelatedId = relatedAlive != null ? SafeId(relatedAlive) : 0,
|
||||
NarrativeDevelopmentId = presentation?.DevelopmentId ?? "",
|
||||
NarrativePresentation = presentation?.Clone(),
|
||||
Label = label ?? assetId ?? key,
|
||||
AssetId = assetId ?? "",
|
||||
SpeciesId = subject?.asset != null ? subject.asset.id : "",
|
||||
|
|
@ -103,7 +106,13 @@ public static class EventFeedUtil
|
|||
MaxWatch = maxWatch,
|
||||
Completion = completion
|
||||
};
|
||||
if (candidate.NarrativePresentation == null)
|
||||
{
|
||||
candidate.NarrativePresentation = NarrativePresentationFactory.Candidate(
|
||||
key, source, assetId, candidate.Label, subject, relatedAlive);
|
||||
}
|
||||
NarrativeFunctionCatalog.Stamp(candidate);
|
||||
NarrativePresentationCoverage.NoteIntake(candidate);
|
||||
if (!EventPresentability.WouldShow(candidate))
|
||||
{
|
||||
InterestDropLog.Record("unpresentable", candidate.Label ?? key);
|
||||
|
|
@ -152,7 +161,19 @@ public static class EventFeedUtil
|
|||
candidate.RelatedId = SafeId(candidate.RelatedUnit);
|
||||
}
|
||||
|
||||
if (candidate.NarrativePresentation == null)
|
||||
{
|
||||
candidate.NarrativePresentation = NarrativePresentationFactory.Candidate(
|
||||
candidate.Key,
|
||||
candidate.Source,
|
||||
candidate.AssetId,
|
||||
candidate.Label,
|
||||
candidate.FollowUnit,
|
||||
candidate.RelatedUnit);
|
||||
}
|
||||
|
||||
NarrativeFunctionCatalog.Stamp(candidate);
|
||||
NarrativePresentationCoverage.NoteIntake(candidate);
|
||||
|
||||
// Harness injects synthetic tip labels for director tests; dossier still blanks them.
|
||||
// Production feeds (Source != harness) must be presentable to enter the registry.
|
||||
|
|
@ -169,6 +190,59 @@ public static class EventFeedUtil
|
|||
private static bool IsHarnessSource(string source) =>
|
||||
string.Equals(source, "harness", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool HarnessProbePresentationCoverage(Actor subject, out string detail)
|
||||
{
|
||||
NarrativePresentationCoverage.Clear();
|
||||
if (subject == null || !subject.isAlive())
|
||||
{
|
||||
detail = "missing living subject";
|
||||
return false;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
string label = SafeName(subject) + " rests after the harvest";
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = "harness:narrative:presentation-coverage:" + SafeId(subject),
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = "Life",
|
||||
Source = "harness",
|
||||
EventStrength = 30f,
|
||||
VisualConfidence = 1f,
|
||||
Position = subject.current_position,
|
||||
FollowUnit = subject,
|
||||
SubjectId = SafeId(subject),
|
||||
Label = label,
|
||||
AssetId = "harness_presentation_coverage",
|
||||
CreatedAt = now,
|
||||
LastSeenAt = now,
|
||||
ExpiresAt = now + 15f,
|
||||
MinWatch = 1f,
|
||||
MaxWatch = 4f,
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
|
||||
string renderedBeat = "";
|
||||
InterestCandidate registered = RegisterCandidate(candidate);
|
||||
bool rendered = registered != null
|
||||
&& NarrativeProse.TryBuildBeat(
|
||||
subject, registered, out _, out renderedBeat, out _);
|
||||
|
||||
bool ok = registered?.NarrativePresentation != null
|
||||
&& rendered
|
||||
&& !string.IsNullOrEmpty(renderedBeat)
|
||||
&& NarrativePresentationCoverage.IntakeStructuredCount == 1
|
||||
&& NarrativePresentationCoverage.IntakeCompatibilityCount == 0
|
||||
&& NarrativePresentationCoverage.CompatibilityCount == 0
|
||||
&& NarrativePresentationCoverage.RedundancyCount == 0;
|
||||
detail = "registered=" + (registered != null)
|
||||
+ " typed=" + (registered?.NarrativePresentation != null)
|
||||
+ " beat='" + renderedBeat + "' "
|
||||
+ NarrativePresentationCoverage.Summary
|
||||
+ " pass=" + ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
public static long SafeId(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
|
|
|
|||
|
|
@ -757,6 +757,8 @@ public static class InterestFeeds
|
|||
}
|
||||
|
||||
Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null;
|
||||
NarrativePresentationModel narrativePresentation =
|
||||
NarrativePresentationFactory.Happiness(occ, subject, related);
|
||||
string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory;
|
||||
bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_");
|
||||
float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId);
|
||||
|
|
@ -856,6 +858,8 @@ public static class InterestFeeds
|
|||
HappinessEffectId = occ.EffectId,
|
||||
StatusId = statusId,
|
||||
CorrelationKey = occ.CorrelationKey,
|
||||
NarrativeDevelopmentId = narrativePresentation?.DevelopmentId ?? "",
|
||||
NarrativePresentation = narrativePresentation,
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + ttl,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ public static class RelationshipInterestFeed
|
|||
private static float _foundingAt = -999f;
|
||||
private static long _foundingActorId;
|
||||
|
||||
public static void Emit(string eventId, Actor subject, Actor related, string labelOverride = null)
|
||||
public static void Emit(
|
||||
string eventId,
|
||||
Actor subject,
|
||||
Actor related,
|
||||
string labelOverride = null,
|
||||
NarrativeEventReceipt receipt = null)
|
||||
{
|
||||
if (!AgentHarness.LiveFeedsAllowed
|
||||
|| subject == null
|
||||
|
|
@ -25,6 +30,8 @@ public static class RelationshipInterestFeed
|
|||
return;
|
||||
}
|
||||
|
||||
NarrativePresentationModel presentation = receipt?.Presentation?.Clone()
|
||||
?? NarrativePresentationFactory.Relationship(eventId, subject, related);
|
||||
Actor relatedAlive = related != null && related.isAlive() ? related : null;
|
||||
|
||||
bool claimsPeer = eventId == "family_group_new"
|
||||
|
|
@ -60,7 +67,8 @@ public static class RelationshipInterestFeed
|
|||
entry.EventStrength,
|
||||
subject.current_position,
|
||||
subject,
|
||||
related: relatedAlive);
|
||||
related: relatedAlive,
|
||||
presentation: presentation);
|
||||
FamilyInterestFeed.EmitFromActor(subject);
|
||||
return;
|
||||
}
|
||||
|
|
@ -76,7 +84,8 @@ public static class RelationshipInterestFeed
|
|||
entry.Id,
|
||||
Mathf.Min(entry.EventStrength, 40f),
|
||||
subject.current_position,
|
||||
subject);
|
||||
subject,
|
||||
presentation: presentation);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +99,8 @@ public static class RelationshipInterestFeed
|
|||
entry.EventStrength,
|
||||
subject.current_position,
|
||||
subject,
|
||||
related: relatedAlive);
|
||||
related: relatedAlive,
|
||||
presentation: presentation);
|
||||
|
||||
// Soft sticky pack scene after real join/form - not from a world family poll.
|
||||
if (eventId == "family_group_new" || eventId == "family_group_join")
|
||||
|
|
@ -119,6 +129,8 @@ public static class RelationshipInterestFeed
|
|||
}
|
||||
|
||||
Actor related = ActorRelation.FirstFamilyPeer(follow);
|
||||
NarrativePresentationModel presentation =
|
||||
NarrativePresentationFactory.Relationship(eventId, follow, related);
|
||||
string label = TypedLabel(eventId, follow, related);
|
||||
string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(follow);
|
||||
if (eventId == "new_family")
|
||||
|
|
@ -136,7 +148,8 @@ public static class RelationshipInterestFeed
|
|||
entry.EventStrength,
|
||||
follow.current_position,
|
||||
follow,
|
||||
related: related);
|
||||
related: related,
|
||||
presentation: presentation);
|
||||
}
|
||||
|
||||
private static void NoteFounding(Actor anchor)
|
||||
|
|
|
|||
|
|
@ -70,15 +70,16 @@ public static class RelationshipEventPatches
|
|||
// setLover is already the confirmed state transition.
|
||||
if (pActor == null)
|
||||
{
|
||||
LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)");
|
||||
NarrativeDevelopmentRouter.BondBroken(__instance, __state, "setLover(null)");
|
||||
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
|
||||
NarrativeEventReceipt receipt =
|
||||
LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)");
|
||||
RelationshipInterestFeed.Emit(
|
||||
"clear_lover", __instance, __state, receipt: receipt);
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover");
|
||||
NarrativeDevelopmentRouter.BondFormed(__instance, pActor, "setLover");
|
||||
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
|
||||
NarrativeEventReceipt formed =
|
||||
LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover");
|
||||
RelationshipInterestFeed.Emit("set_lover", __instance, pActor, receipt: formed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -127,9 +128,10 @@ public static class RelationshipEventPatches
|
|||
continue;
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup");
|
||||
NarrativeDevelopmentRouter.BondBroken(unit, lover, "dead_lover_cleanup");
|
||||
RelationshipInterestFeed.Emit("clear_lover", unit, null);
|
||||
NarrativeEventReceipt receipt =
|
||||
LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup");
|
||||
RelationshipInterestFeed.Emit(
|
||||
"clear_lover", unit, lover, receipt: receipt);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -170,9 +172,8 @@ public static class RelationshipEventPatches
|
|||
&& EventOutcome.IsLiving(child),
|
||||
() =>
|
||||
{
|
||||
LifeSagaMemory.RecordParentChild(parent, child);
|
||||
NarrativeDevelopmentRouter.ChildBorn(parent, child, "setParent");
|
||||
RelationshipInterestFeed.Emit("add_child", parent, child);
|
||||
NarrativeEventReceipt receipt = LifeSagaMemory.RecordParentChild(parent, child);
|
||||
RelationshipInterestFeed.Emit("add_child", parent, child, receipt: receipt);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +195,6 @@ public static class RelationshipEventPatches
|
|||
}
|
||||
|
||||
LifeSagaMemory.RecordFounding(anchor, "family", familyKey, "new_family");
|
||||
NarrativeDevelopmentRouter.Founding(anchor, "family", familyKey, "new_family");
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
|
||||
|
|
@ -455,7 +455,6 @@ public static class RelationshipEventPatches
|
|||
() =>
|
||||
{
|
||||
LifeSagaMemory.RecordRole(pActor, "become_alpha", "family_set_alpha");
|
||||
NarrativeDevelopmentRouter.Role(pActor, "family alpha", true, "family_set_alpha");
|
||||
InterestFeeds.EmitAlpha(pActor, "family_set_alpha");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,31 @@ internal static class HarnessScenarios
|
|||
case "narrative_scheduler_policy":
|
||||
case "narrative_interrupt_policy":
|
||||
return NarrativeSchedulerPolicy();
|
||||
case "narrative_episode_grammar":
|
||||
case "narrative_episode_no_forced_footage":
|
||||
return NarrativeEpisodeGrammar();
|
||||
case "narrative_ensemble_balance":
|
||||
case "narrative_prolific_mc_guard":
|
||||
case "narrative_dormant_reintroduction":
|
||||
return NarrativeEnsembleBalance();
|
||||
case "narrative_prose_redundancy":
|
||||
case "narrative_relationship_prose":
|
||||
return NarrativeProseRedundancy();
|
||||
case "narrative_presentation_coverage":
|
||||
case "narrative_typed_intake":
|
||||
return NarrativePresentationCoverage();
|
||||
case "narrative_relationship_ingestion":
|
||||
case "narrative_ingestion_idempotent":
|
||||
return NarrativeRelationshipIngestion();
|
||||
case "narrative_canonical_ingestion":
|
||||
case "narrative_ingestion_matrix":
|
||||
return NarrativeCanonicalIngestion();
|
||||
case "narrative_persistence_roundtrip":
|
||||
case "narrative_persistence_no_replay":
|
||||
return NarrativePersistenceRoundTrip();
|
||||
case "narrative_persistence_corrupt":
|
||||
case "narrative_persistence_unknown_version":
|
||||
return NarrativePersistenceInvalid();
|
||||
case "narrative_camera_cutover":
|
||||
case "story_first_camera":
|
||||
return NarrativeCameraCutover();
|
||||
|
|
@ -3168,6 +3193,110 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> NarrativeEpisodeGrammar()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("neg0", "dismiss_windows"),
|
||||
Step("neg1", "wait_world"),
|
||||
Step("neg2", "narrative_episode_grammar_probe"),
|
||||
Step("neg3", "assert", expect: "no_bad"),
|
||||
Step("neg99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> NarrativeEnsembleBalance()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("neb0", "dismiss_windows"),
|
||||
Step("neb1", "wait_world"),
|
||||
Step("neb2", "narrative_ensemble_balance_probe"),
|
||||
Step("neb3", "assert", expect: "no_bad"),
|
||||
Step("neb99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> NarrativeProseRedundancy()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("npr0", "dismiss_windows"),
|
||||
Step("npr1", "wait_world"),
|
||||
Step("npr2", "narrative_prose_redundancy_probe"),
|
||||
Step("npr3", "assert", expect: "no_bad"),
|
||||
Step("npr99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> NarrativePresentationCoverage()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("npc0", "dismiss_windows"),
|
||||
Step("npc1", "wait_world"),
|
||||
Step("npc2", "spawn", asset: "human", count: 1),
|
||||
Step("npc3", "focus", asset: "human"),
|
||||
Step("npc4", "narrative_presentation_coverage_probe"),
|
||||
Step("npc5", "assert", expect: "no_bad"),
|
||||
Step("npc99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> NarrativeRelationshipIngestion()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("nri0", "dismiss_windows"),
|
||||
Step("nri1", "wait_world"),
|
||||
Step("nri2", "spawn", asset: "human", count: 3),
|
||||
Step("nri3", "focus", asset: "human"),
|
||||
Step("nri4", "narrative_relationship_ingestion_probe"),
|
||||
Step("nri5", "assert", expect: "no_bad"),
|
||||
Step("nri99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> NarrativeCanonicalIngestion()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("nci0", "dismiss_windows"),
|
||||
Step("nci1", "wait_world"),
|
||||
Step("nci2", "spawn", asset: "human", count: 2),
|
||||
Step("nci3", "focus", asset: "human"),
|
||||
Step("nci4", "narrative_ingestion_matrix_probe"),
|
||||
Step("nci5", "assert", expect: "no_bad"),
|
||||
Step("nci99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> NarrativePersistenceRoundTrip()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("npr0", "dismiss_windows"),
|
||||
Step("npr1", "wait_world"),
|
||||
Step("npr2", "spawn", asset: "human", count: 2),
|
||||
Step("npr3", "focus", asset: "human"),
|
||||
Step("npr4", "narrative_persistence_probe"),
|
||||
Step("npr5", "assert", expect: "no_bad"),
|
||||
Step("npr99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> NarrativePersistenceInvalid()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("npi0", "dismiss_windows"),
|
||||
Step("npi1", "wait_world"),
|
||||
Step("npi2", "narrative_persistence_invalid_probe"),
|
||||
Step("npi3", "assert", expect: "no_bad"),
|
||||
Step("npi99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> NarrativeCameraCutover()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ public sealed class InterestCandidate
|
|||
public NarrativeFunction NarrativeFunction;
|
||||
public bool HasNarrativeFunction;
|
||||
public string NarrativeFunctionSource = "";
|
||||
/// <summary>Exact semantic development represented by this candidate, when durable.</summary>
|
||||
public string NarrativeDevelopmentId = "";
|
||||
/// <summary>Complete typed orange-reason input. Final English is rendered at presentation.</summary>
|
||||
public NarrativePresentationModel NarrativePresentation;
|
||||
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
|
||||
public int ParticipantCount;
|
||||
/// <summary>Sticky multi-actor scoreboard (combat first; war/plot/family later).</summary>
|
||||
|
|
@ -323,6 +327,8 @@ public sealed class InterestCandidate
|
|||
NarrativeFunction = NarrativeFunction,
|
||||
HasNarrativeFunction = HasNarrativeFunction,
|
||||
NarrativeFunctionSource = NarrativeFunctionSource,
|
||||
NarrativeDevelopmentId = NarrativeDevelopmentId,
|
||||
NarrativePresentation = NarrativePresentation?.Clone(),
|
||||
ParticipantCount = ParticipantCount,
|
||||
NotableParticipantCount = NotableParticipantCount,
|
||||
Position = Position,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
|
|
@ -8,6 +9,8 @@ public sealed class EpisodeShotProposal
|
|||
public NarrativeInterruptClass InterruptClass;
|
||||
public string Reason = "";
|
||||
public float EditorialScore;
|
||||
public EpisodeShotRole Role;
|
||||
public string SemanticKey = "";
|
||||
}
|
||||
|
||||
/// <summary>Selects the best presentable shot that can explain its place in the active episode.</summary>
|
||||
|
|
@ -33,13 +36,31 @@ public static class EpisodeShotSelector
|
|||
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
|
||||
if (function == NarrativeFunction.Noise) continue;
|
||||
NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
|
||||
float score = EditorialScore(c, episode, kind, function);
|
||||
EpisodeShotRole role = RoleFor(c, function, kind, episode);
|
||||
string semanticKey = SemanticKey(c, role);
|
||||
bool related = kind == NarrativeInterruptClass.RelatedEscalation;
|
||||
if (related
|
||||
&& !AdvancesEpisode(episode, role)
|
||||
&& episode.RecentSemanticKeys.Contains(semanticKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (related && function == NarrativeFunction.CharacterTexture
|
||||
&& role != EpisodeShotRole.Reintroduction)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float score = EditorialScore(c, episode, kind, function, role);
|
||||
var proposal = new EpisodeShotProposal
|
||||
{
|
||||
Candidate = c,
|
||||
InterruptClass = kind,
|
||||
EditorialScore = score,
|
||||
Reason = Reason(kind)
|
||||
Role = role,
|
||||
SemanticKey = semanticKey,
|
||||
Reason = Reason(kind) + ":" + role
|
||||
};
|
||||
if (kind == NarrativeInterruptClass.RelatedEscalation)
|
||||
{
|
||||
|
|
@ -67,18 +88,74 @@ public static class EpisodeShotSelector
|
|||
InterestCandidate c,
|
||||
EpisodePlan episode,
|
||||
NarrativeInterruptClass kind,
|
||||
NarrativeFunction function)
|
||||
NarrativeFunction function,
|
||||
EpisodeShotRole role)
|
||||
{
|
||||
float relation = c.SubjectId == episode.ProtagonistId ? 40f
|
||||
: c.RelatedId == episode.ProtagonistId ? 30f : 0f;
|
||||
float interrupt = kind == NarrativeInterruptClass.WorldCritical ? 80f
|
||||
float interrupt = kind == NarrativeInterruptClass.WorldCritical ? 90f
|
||||
: kind == NarrativeInterruptClass.StoryPayoff ? 35f
|
||||
: kind == NarrativeInterruptClass.RelatedEscalation ? 25f : 0f;
|
||||
return relation + interrupt + NarrativeFunctionPolicy.EditorialBonus(function)
|
||||
float roleProgress = kind == NarrativeInterruptClass.RelatedEscalation
|
||||
? (AdvancesEpisode(episode, role) ? 32f : -12f)
|
||||
: 0f;
|
||||
float subjectRepeat = c.SubjectId != 0 && c.SubjectId == episode.LastSubjectId
|
||||
? -8f * Mathf.Min(3, episode.RepeatedSubjectCount)
|
||||
: 0f;
|
||||
float balance = NarrativeExposureLedger.CharacterDebt(episode.ProtagonistId) * 1.5f;
|
||||
float repetition = NarrativeExposureLedger.RepetitionPenalty(
|
||||
episode.ProtagonistId, StoryScheduler.SemanticFamily(c));
|
||||
return relation + interrupt + roleProgress + subjectRepeat
|
||||
+ balance - repetition
|
||||
+ NarrativeFunctionPolicy.EditorialBonus(function)
|
||||
+ c.EventStrength * 0.35f
|
||||
+ c.VisualConfidence * 10f + c.Novelty * 3f;
|
||||
}
|
||||
|
||||
public static EpisodeShotRole RoleFor(
|
||||
InterestCandidate candidate,
|
||||
NarrativeFunction function,
|
||||
NarrativeInterruptClass interrupt,
|
||||
EpisodePlan episode)
|
||||
{
|
||||
if (interrupt == NarrativeInterruptClass.WorldCritical)
|
||||
return EpisodeShotRole.WorldContext;
|
||||
switch (function)
|
||||
{
|
||||
case NarrativeFunction.ThreadOpener: return EpisodeShotRole.Establish;
|
||||
case NarrativeFunction.Development: return EpisodeShotRole.Development;
|
||||
case NarrativeFunction.Escalation: return EpisodeShotRole.Pressure;
|
||||
case NarrativeFunction.TurningPoint: return EpisodeShotRole.TurningPoint;
|
||||
case NarrativeFunction.Resolution: return EpisodeShotRole.Payoff;
|
||||
case NarrativeFunction.Consequence: return EpisodeShotRole.Consequence;
|
||||
case NarrativeFunction.CharacterTexture:
|
||||
return episode != null && episode.CoveredRoles.Count == 0
|
||||
? EpisodeShotRole.Reintroduction
|
||||
: EpisodeShotRole.Unknown;
|
||||
case NarrativeFunction.WorldContext: return EpisodeShotRole.WorldContext;
|
||||
default: return EpisodeShotRole.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool AdvancesEpisode(EpisodePlan episode, EpisodeShotRole role)
|
||||
{
|
||||
if (episode == null || role == EpisodeShotRole.Unknown
|
||||
|| role == EpisodeShotRole.WorldContext)
|
||||
return false;
|
||||
return episode.PendingRoles.Contains(role) || !episode.HasCovered(role);
|
||||
}
|
||||
|
||||
public static string SemanticKey(InterestCandidate candidate, EpisodeShotRole role)
|
||||
{
|
||||
if (candidate == null) return "";
|
||||
string action = candidate.NarrativePresentation?.ActionId;
|
||||
if (string.IsNullOrEmpty(action))
|
||||
action = candidate.HappinessEffectId;
|
||||
if (string.IsNullOrEmpty(action))
|
||||
action = candidate.AssetId;
|
||||
return role + ":" + (action ?? "") + ":" + candidate.SubjectId;
|
||||
}
|
||||
|
||||
private static string Reason(NarrativeInterruptClass kind)
|
||||
{
|
||||
switch (kind)
|
||||
|
|
|
|||
392
IdleSpectator/Narrative/LegacyChapterBuilder.cs
Normal file
392
IdleSpectator/Narrative/LegacyChapterBuilder.cs
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Sole Legacy author. It consolidates semantic consequences and the few narrative facts that do
|
||||
/// not yet project a consequence (earned rivalry and hard-arc observations) before presentation.
|
||||
/// </summary>
|
||||
public static class LegacyChapterBuilder
|
||||
{
|
||||
private const int RetainedChapterLimit = 24;
|
||||
|
||||
public static List<LifeSagaLegacyBeat> Build(
|
||||
long characterId,
|
||||
string currentTitle,
|
||||
LifeSagaFact currentStake,
|
||||
IReadOnlyCollection<long> currentCast,
|
||||
int displayLimit = 4)
|
||||
{
|
||||
var candidates = new List<LifeSagaLegacyBeat>(RetainedChapterLimit);
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AddSemanticChapters(characterId, candidates, seen);
|
||||
ConsolidateLineage(characterId, candidates, seen);
|
||||
ConsolidateCombat(characterId, candidates, seen);
|
||||
AddFactOnlyChapters(
|
||||
characterId, currentTitle, currentStake, currentCast, candidates, seen);
|
||||
|
||||
candidates.Sort(Compare);
|
||||
SelectDiverse(candidates, displayLimit);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static void AddSemanticChapters(
|
||||
long characterId,
|
||||
List<LifeSagaLegacyBeat> into,
|
||||
HashSet<string> seen)
|
||||
{
|
||||
List<NarrativeChapter> chapters =
|
||||
NarrativeStoryStore.ChaptersFor(characterId, RetainedChapterLimit);
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
NarrativeChapter chapter = chapters[i];
|
||||
if (chapter == null || string.IsNullOrEmpty(chapter.Line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string line = WithDate(chapter.DateLabel, chapter.Line);
|
||||
if (!seen.Add(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
NarrativeThread thread = NarrativeStoryStore.Thread(chapter.ThreadId);
|
||||
into.Add(new LifeSagaLegacyBeat
|
||||
{
|
||||
ChapterId = chapter.Id ?? "",
|
||||
SourceThreadId = chapter.ThreadId ?? "",
|
||||
SourceDevelopmentId = SourceDevelopmentId(chapter.Id),
|
||||
Kind = FactKind(thread),
|
||||
Line = line,
|
||||
Truth = "observed",
|
||||
At = chapter.At,
|
||||
DateLabel = chapter.DateLabel ?? "",
|
||||
Importance = chapter.Importance + CompletenessBonus(thread)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConsolidateLineage(
|
||||
long characterId,
|
||||
List<LifeSagaLegacyBeat> candidates,
|
||||
HashSet<string> seen)
|
||||
{
|
||||
int children = NarrativeStoryStore.CountConsequences(
|
||||
characterId, CharacterConsequenceKind.BecameParent);
|
||||
if (children < 3)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = candidates.Count - 1; i >= 0; i--)
|
||||
{
|
||||
NarrativeThread thread = NarrativeStoryStore.Thread(candidates[i].SourceThreadId);
|
||||
if (thread != null && thread.Kind == NarrativeThreadKind.Lineage)
|
||||
{
|
||||
seen.Remove(candidates[i].Line);
|
||||
candidates.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
string line = "Raised a lineage of " + children + " children";
|
||||
if (seen.Add(line))
|
||||
{
|
||||
candidates.Add(new LifeSagaLegacyBeat
|
||||
{
|
||||
ChapterId = "legacy:lineage:" + characterId,
|
||||
Kind = LifeSagaFactKind.ParentChild,
|
||||
Line = line,
|
||||
Truth = "observed",
|
||||
Importance = 92f
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConsolidateCombat(
|
||||
long characterId,
|
||||
List<LifeSagaLegacyBeat> candidates,
|
||||
HashSet<string> seen)
|
||||
{
|
||||
IReadOnlyList<LifeSagaFact> facts = LifeSagaMemory.FactsFor(characterId);
|
||||
int kills = 0;
|
||||
LifeSagaFact earliest = null;
|
||||
LifeSagaFact latest = null;
|
||||
for (int i = 0; i < facts.Count; i++)
|
||||
{
|
||||
LifeSagaFact fact = facts[i];
|
||||
if (fact == null || fact.Kind != LifeSagaFactKind.Kill)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
kills++;
|
||||
if (earliest == null || fact.WorldTime < earliest.WorldTime) earliest = fact;
|
||||
if (latest == null || fact.WorldTime > latest.WorldTime) latest = fact;
|
||||
}
|
||||
|
||||
if (kills < 2 || latest == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = candidates.Count - 1; i >= 0; i--)
|
||||
{
|
||||
NarrativeThread thread = NarrativeStoryStore.Thread(candidates[i].SourceThreadId);
|
||||
if (candidates[i].Kind == LifeSagaFactKind.Kill
|
||||
|| (thread != null && thread.Kind == NarrativeThreadKind.PersonalCombat))
|
||||
{
|
||||
seen.Remove(candidates[i].Line);
|
||||
candidates.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
string date = DateRange(earliest?.DateLabel, latest.DateLabel);
|
||||
string line = WithDate(date, "Slew " + kills + " opponents across repeated clashes");
|
||||
if (seen.Add(line))
|
||||
{
|
||||
candidates.Add(new LifeSagaLegacyBeat
|
||||
{
|
||||
ChapterId = "legacy:kills:" + characterId,
|
||||
Kind = LifeSagaFactKind.Kill,
|
||||
Line = line,
|
||||
Truth = "observed",
|
||||
At = latest.At,
|
||||
DateLabel = date,
|
||||
Importance = Mathf.Min(98f, 76f + kills * 3f)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddFactOnlyChapters(
|
||||
long characterId,
|
||||
string currentTitle,
|
||||
LifeSagaFact currentStake,
|
||||
IReadOnlyCollection<long> currentCast,
|
||||
List<LifeSagaLegacyBeat> candidates,
|
||||
HashSet<string> seen)
|
||||
{
|
||||
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(characterId, RetainedChapterLimit);
|
||||
for (int i = 0; i < facts.Count; i++)
|
||||
{
|
||||
LifeSagaFact fact = facts[i];
|
||||
if (fact == null || CoveredBySemanticProjection(fact)
|
||||
|| IsCurrentStake(fact, currentStake)
|
||||
|| EchoesCurrentCast(fact, currentCast)
|
||||
|| SagaProse.RoleChangeEchoesTitle(fact, currentTitle)
|
||||
|| (fact.Kind == LifeSagaFactKind.Founding
|
||||
&& !string.IsNullOrEmpty(currentTitle)
|
||||
&& currentTitle.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string line = LifeSagaMemory.FormatLegacyLine(fact);
|
||||
if (string.IsNullOrEmpty(line) || !seen.Add(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.Add(new LifeSagaLegacyBeat
|
||||
{
|
||||
ChapterId = "legacy:fact:" + (fact.Key ?? i.ToString()),
|
||||
SourceEventId = EventIdForFact(fact),
|
||||
Kind = fact.Kind,
|
||||
Line = line,
|
||||
Truth = "observed",
|
||||
At = fact.At,
|
||||
DateLabel = fact.DateLabel ?? "",
|
||||
Importance = fact.Strength
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CoveredBySemanticProjection(LifeSagaFact fact)
|
||||
{
|
||||
switch (fact.Kind)
|
||||
{
|
||||
case LifeSagaFactKind.BondFormed:
|
||||
case LifeSagaFactKind.BondBroken:
|
||||
case LifeSagaFactKind.FriendFormed:
|
||||
case LifeSagaFactKind.ParentChild:
|
||||
case LifeSagaFactKind.Kill:
|
||||
case LifeSagaFactKind.Death:
|
||||
case LifeSagaFactKind.RoleChange:
|
||||
case LifeSagaFactKind.Founding:
|
||||
case LifeSagaFactKind.HomeGained:
|
||||
case LifeSagaFactKind.HomeLost:
|
||||
case LifeSagaFactKind.WarJoin:
|
||||
case LifeSagaFactKind.WarEnd:
|
||||
case LifeSagaFactKind.PlotJoin:
|
||||
case LifeSagaFactKind.PlotEnd:
|
||||
return NarrativeEventStore.Count > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCurrentStake(LifeSagaFact fact, LifeSagaFact stake)
|
||||
{
|
||||
if (fact == null || stake == null) return false;
|
||||
return (!string.IsNullOrEmpty(stake.Key)
|
||||
&& string.Equals(stake.Key, fact.Key, StringComparison.Ordinal))
|
||||
|| (stake.Kind == fact.Kind && stake.OtherId != 0 && stake.OtherId == fact.OtherId);
|
||||
}
|
||||
|
||||
private static bool EchoesCurrentCast(
|
||||
LifeSagaFact fact,
|
||||
IReadOnlyCollection<long> currentCast)
|
||||
{
|
||||
if (fact == null || currentCast == null || fact.OtherId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool contains = false;
|
||||
foreach (long id in currentCast)
|
||||
{
|
||||
if (id == fact.OtherId)
|
||||
{
|
||||
contains = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return contains
|
||||
&& (fact.Kind == LifeSagaFactKind.BondFormed
|
||||
|| fact.Kind == LifeSagaFactKind.FriendFormed
|
||||
|| fact.Kind == LifeSagaFactKind.ParentChild);
|
||||
}
|
||||
|
||||
private static int Compare(LifeSagaLegacyBeat a, LifeSagaLegacyBeat b)
|
||||
{
|
||||
int importance = b.Importance.CompareTo(a.Importance);
|
||||
return importance != 0 ? importance : b.At.CompareTo(a.At);
|
||||
}
|
||||
|
||||
private static void SelectDiverse(List<LifeSagaLegacyBeat> candidates, int limit)
|
||||
{
|
||||
if (limit <= 0)
|
||||
{
|
||||
candidates.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = new List<LifeSagaLegacyBeat>(limit);
|
||||
var buckets = new HashSet<int>();
|
||||
for (int pass = 0; pass < 2 && selected.Count < limit; pass++)
|
||||
{
|
||||
for (int i = 0; i < candidates.Count && selected.Count < limit; i++)
|
||||
{
|
||||
LifeSagaLegacyBeat candidate = candidates[i];
|
||||
if (candidate == null || selected.Contains(candidate)) continue;
|
||||
int bucket = Bucket(candidate.Kind);
|
||||
if (pass == 0 && !buckets.Add(bucket)) continue;
|
||||
selected.Add(candidate);
|
||||
buckets.Add(bucket);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.Clear();
|
||||
candidates.AddRange(selected);
|
||||
}
|
||||
|
||||
private static int Bucket(LifeSagaFactKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case LifeSagaFactKind.BondFormed:
|
||||
case LifeSagaFactKind.BondBroken:
|
||||
case LifeSagaFactKind.FriendFormed:
|
||||
case LifeSagaFactKind.ParentChild: return 1;
|
||||
case LifeSagaFactKind.RoleChange:
|
||||
case LifeSagaFactKind.Founding:
|
||||
case LifeSagaFactKind.HomeGained:
|
||||
case LifeSagaFactKind.HomeLost: return 2;
|
||||
case LifeSagaFactKind.WarJoin:
|
||||
case LifeSagaFactKind.WarEnd:
|
||||
case LifeSagaFactKind.PlotJoin:
|
||||
case LifeSagaFactKind.PlotEnd: return 3;
|
||||
case LifeSagaFactKind.Kill:
|
||||
case LifeSagaFactKind.RivalEarned:
|
||||
case LifeSagaFactKind.CombatEncounter: return 4;
|
||||
case LifeSagaFactKind.Death:
|
||||
case LifeSagaFactKind.HardArcGrief: return 5;
|
||||
default: return 6;
|
||||
}
|
||||
}
|
||||
|
||||
private static LifeSagaFactKind FactKind(NarrativeThread thread)
|
||||
{
|
||||
if (thread == null) return LifeSagaFactKind.Unknown;
|
||||
switch (thread.Kind)
|
||||
{
|
||||
case NarrativeThreadKind.Courtship:
|
||||
case NarrativeThreadKind.Partnership: return LifeSagaFactKind.BondFormed;
|
||||
case NarrativeThreadKind.SeparationGrief: return LifeSagaFactKind.BondBroken;
|
||||
case NarrativeThreadKind.Lineage: return LifeSagaFactKind.ParentChild;
|
||||
case NarrativeThreadKind.PersonalCombat: return LifeSagaFactKind.Kill;
|
||||
case NarrativeThreadKind.Rivalry: return LifeSagaFactKind.RivalEarned;
|
||||
case NarrativeThreadKind.WarInvolvement: return LifeSagaFactKind.WarJoin;
|
||||
case NarrativeThreadKind.PlotBetrayal: return LifeSagaFactKind.PlotJoin;
|
||||
case NarrativeThreadKind.RiseToRule:
|
||||
case NarrativeThreadKind.Reign:
|
||||
case NarrativeThreadKind.Succession: return LifeSagaFactKind.RoleChange;
|
||||
case NarrativeThreadKind.Founding: return LifeSagaFactKind.Founding;
|
||||
case NarrativeThreadKind.HomeLoss: return LifeSagaFactKind.HomeLost;
|
||||
case NarrativeThreadKind.Transformation: return LifeSagaFactKind.HardArcLove;
|
||||
case NarrativeThreadKind.Recovery: return LifeSagaFactKind.HomeGained;
|
||||
default: return LifeSagaFactKind.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
private static float CompletenessBonus(NarrativeThread thread)
|
||||
{
|
||||
if (thread == null) return 0f;
|
||||
if (thread.Status == NarrativeThreadStatus.Resolved) return 14f;
|
||||
if (thread.Phase == NarrativePhase.Outcome || thread.Phase == NarrativePhase.Consequence)
|
||||
{
|
||||
return 9f;
|
||||
}
|
||||
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private static string SourceDevelopmentId(string consequenceId)
|
||||
{
|
||||
CharacterConsequence consequence = NarrativeStoryStore.Consequence(consequenceId);
|
||||
return consequence?.DevelopmentId ?? "";
|
||||
}
|
||||
|
||||
private static string EventIdForFact(LifeSagaFact fact)
|
||||
{
|
||||
if (fact == null) return "";
|
||||
foreach (NarrativeEventRecord record in NarrativeEventStore.All())
|
||||
{
|
||||
if (record != null
|
||||
&& (record.CorrelationKey == fact.Key
|
||||
|| record.DevelopmentId == fact.Key))
|
||||
{
|
||||
return record.Id;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string WithDate(string date, string line) =>
|
||||
string.IsNullOrEmpty(date) ? line ?? "" : date + " · " + (line ?? "");
|
||||
|
||||
private static string DateRange(string first, string last)
|
||||
{
|
||||
if (string.IsNullOrEmpty(first)) return last ?? "";
|
||||
if (string.IsNullOrEmpty(last) || string.Equals(first, last, StringComparison.Ordinal))
|
||||
{
|
||||
return first;
|
||||
}
|
||||
|
||||
return first + "–" + last;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,75 +7,250 @@ public static class NarrativeDevelopmentRouter
|
|||
{
|
||||
public static bool BondFormed(Actor subject, Actor partner, string source)
|
||||
{
|
||||
return BondFormed(subject, partner, source, out _);
|
||||
}
|
||||
|
||||
public static bool BondFormed(Actor subject, Actor partner, string source, out string developmentId)
|
||||
{
|
||||
developmentId = BondFormedId(subject, partner);
|
||||
return Record(NarrativeDevelopmentKind.BondFormed, NarrativeFunction.ThreadOpener,
|
||||
subject, partner, "bond:" + Pair(subject, partner) + ":formed:" + TimeBucket(), source, magnitude: 72f);
|
||||
subject, partner, developmentId, source, magnitude: 72f);
|
||||
}
|
||||
|
||||
public static bool BondBroken(Actor subject, Actor former, string source)
|
||||
{
|
||||
return BondBroken(subject, former, source, out _);
|
||||
}
|
||||
|
||||
public static bool BondBroken(Actor subject, Actor former, string source, out string developmentId)
|
||||
{
|
||||
developmentId = BondBrokenId(subject, former);
|
||||
return Record(NarrativeDevelopmentKind.BondBroken, NarrativeFunction.TurningPoint,
|
||||
subject, former, "bond:" + Pair(subject, former) + ":broken:" + TimeBucket(), source, magnitude: 88f,
|
||||
subject, former, developmentId, source, magnitude: 88f,
|
||||
outcome: "partnership ended");
|
||||
}
|
||||
|
||||
public static bool ChildBorn(Actor parent, Actor child, string source)
|
||||
{
|
||||
return ChildBorn(parent, child, source, out _);
|
||||
}
|
||||
|
||||
public static bool ChildBorn(Actor parent, Actor child, string source, out string developmentId)
|
||||
{
|
||||
string family = FamilyKey(parent);
|
||||
developmentId = ChildBornId(parent, child);
|
||||
return Record(NarrativeDevelopmentKind.ChildBorn, NarrativeFunction.Consequence,
|
||||
parent, child, "child:" + EventFeedUtil.SafeId(parent) + ":" + EventFeedUtil.SafeId(child),
|
||||
parent, child, developmentId,
|
||||
source, familyKey: family, magnitude: 76f, outcome: "child born");
|
||||
}
|
||||
|
||||
public static bool FriendFormed(Actor subject, Actor friend, string source)
|
||||
{
|
||||
return Record(NarrativeDevelopmentKind.FriendFormed, NarrativeFunction.ThreadOpener,
|
||||
subject, friend, "friend:" + Pair(subject, friend), source, magnitude: 55f);
|
||||
return FriendFormed(subject, friend, source, out _);
|
||||
}
|
||||
|
||||
public static bool FriendFormed(Actor subject, Actor friend, string source, out string developmentId)
|
||||
{
|
||||
developmentId = FriendFormedId(subject, friend);
|
||||
return Record(NarrativeDevelopmentKind.FriendFormed, NarrativeFunction.ThreadOpener,
|
||||
subject, friend, developmentId, source, magnitude: 55f);
|
||||
}
|
||||
|
||||
public static string BondFormedId(Actor subject, Actor partner) =>
|
||||
"bond:" + Pair(subject, partner) + ":formed:" + TimeBucket();
|
||||
|
||||
public static string BondBrokenId(Actor subject, Actor former) =>
|
||||
"bond:" + Pair(subject, former) + ":broken:" + TimeBucket();
|
||||
|
||||
public static string ChildBornId(Actor parent, Actor child) =>
|
||||
"child:" + EventFeedUtil.SafeId(parent) + ":" + EventFeedUtil.SafeId(child);
|
||||
|
||||
public static string FriendFormedId(Actor subject, Actor friend) =>
|
||||
"friend:" + Pair(subject, friend);
|
||||
|
||||
internal static bool BondFormedExact(
|
||||
Actor subject,
|
||||
Actor partner,
|
||||
string source,
|
||||
string developmentId) =>
|
||||
Record(NarrativeDevelopmentKind.BondFormed, NarrativeFunction.ThreadOpener,
|
||||
subject, partner, developmentId, source, magnitude: 72f);
|
||||
|
||||
internal static bool BondBrokenExact(
|
||||
Actor subject,
|
||||
Actor former,
|
||||
string source,
|
||||
string developmentId) =>
|
||||
Record(NarrativeDevelopmentKind.BondBroken, NarrativeFunction.TurningPoint,
|
||||
subject, former, developmentId, source, magnitude: 88f,
|
||||
outcome: "partnership ended");
|
||||
|
||||
internal static bool ChildBornExact(
|
||||
Actor parent,
|
||||
Actor child,
|
||||
string source,
|
||||
string developmentId) =>
|
||||
Record(NarrativeDevelopmentKind.ChildBorn, NarrativeFunction.Consequence,
|
||||
parent, child, developmentId, source, familyKey: FamilyKey(parent),
|
||||
magnitude: 76f, outcome: "child born");
|
||||
|
||||
internal static bool FriendFormedExact(
|
||||
Actor subject,
|
||||
Actor friend,
|
||||
string source,
|
||||
string developmentId) =>
|
||||
Record(NarrativeDevelopmentKind.FriendFormed, NarrativeFunction.ThreadOpener,
|
||||
subject, friend, developmentId, source, magnitude: 55f);
|
||||
|
||||
public static bool Death(Actor victim, Actor killer, string attackType, string source)
|
||||
{
|
||||
return DeathExact(victim, killer, attackType, source, DeathId(victim));
|
||||
}
|
||||
|
||||
public static string DeathId(Actor victim) =>
|
||||
"death:" + EventFeedUtil.SafeId(victim);
|
||||
|
||||
internal static bool DeathExact(
|
||||
Actor victim,
|
||||
Actor killer,
|
||||
string attackType,
|
||||
string source,
|
||||
string developmentId)
|
||||
{
|
||||
return Record(NarrativeDevelopmentKind.Death, NarrativeFunction.Resolution,
|
||||
victim, killer, "death:" + EventFeedUtil.SafeId(victim), source,
|
||||
victim, killer, developmentId, source,
|
||||
newState: "dead", outcome: attackType ?? "", magnitude: 100f);
|
||||
}
|
||||
|
||||
public static bool Kill(Actor killer, Actor victim, string source)
|
||||
{
|
||||
return KillExact(killer, victim, source, KillId(killer, victim));
|
||||
}
|
||||
|
||||
public static string KillId(Actor killer, Actor victim) =>
|
||||
"kill:" + EventFeedUtil.SafeId(killer) + ":" + EventFeedUtil.SafeId(victim);
|
||||
|
||||
internal static bool KillExact(
|
||||
Actor killer,
|
||||
Actor victim,
|
||||
string source,
|
||||
string developmentId)
|
||||
{
|
||||
return Record(NarrativeDevelopmentKind.Kill, NarrativeFunction.TurningPoint,
|
||||
killer, victim, "kill:" + EventFeedUtil.SafeId(killer) + ":" + EventFeedUtil.SafeId(victim),
|
||||
killer, victim, developmentId,
|
||||
source, outcome: "victim died", magnitude: 82f);
|
||||
}
|
||||
|
||||
public static bool Role(Actor subject, string role, bool gained, string source)
|
||||
{
|
||||
return RoleExact(subject, role, gained, source, RoleId(subject, role, gained));
|
||||
}
|
||||
|
||||
public static string RoleId(Actor subject, string role, bool gained) =>
|
||||
"role:" + EventFeedUtil.SafeId(subject) + ":" + (role ?? "") + ":" + gained;
|
||||
|
||||
internal static bool RoleExact(
|
||||
Actor subject,
|
||||
string role,
|
||||
bool gained,
|
||||
string source,
|
||||
string developmentId)
|
||||
{
|
||||
return Record(gained ? NarrativeDevelopmentKind.RoleGained : NarrativeDevelopmentKind.RoleLost,
|
||||
gained ? NarrativeFunction.TurningPoint : NarrativeFunction.Consequence,
|
||||
subject, null, "role:" + EventFeedUtil.SafeId(subject) + ":" + (role ?? ""), source,
|
||||
subject, null, developmentId, source,
|
||||
newState: role, magnitude: 78f);
|
||||
}
|
||||
|
||||
public static bool Home(Actor subject, bool gained, string source)
|
||||
{
|
||||
return HomeExact(subject, gained, source, HomeId(subject, gained));
|
||||
}
|
||||
|
||||
public static string HomeId(Actor subject, bool gained) =>
|
||||
"home:" + EventFeedUtil.SafeId(subject) + ":" + gained;
|
||||
|
||||
internal static bool HomeExact(
|
||||
Actor subject,
|
||||
bool gained,
|
||||
string source,
|
||||
string developmentId)
|
||||
{
|
||||
return Record(
|
||||
gained ? NarrativeDevelopmentKind.HomeFounded : NarrativeDevelopmentKind.HomeLost,
|
||||
gained ? NarrativeFunction.TurningPoint : NarrativeFunction.Consequence,
|
||||
subject,
|
||||
null,
|
||||
developmentId,
|
||||
source,
|
||||
newState: gained ? "home" : "",
|
||||
outcome: gained ? "" : "home lost",
|
||||
magnitude: gained ? 64f : 72f);
|
||||
}
|
||||
|
||||
public static bool Founding(Actor subject, string kind, string key, string source)
|
||||
{
|
||||
return FoundingExact(subject, kind, key, source, FoundingId(subject, kind, key));
|
||||
}
|
||||
|
||||
public static string FoundingId(Actor subject, string kind, string key) =>
|
||||
"founding:" + (kind ?? "") + ":" + (key ?? "") + ":" + EventFeedUtil.SafeId(subject);
|
||||
|
||||
internal static bool FoundingExact(
|
||||
Actor subject,
|
||||
string kind,
|
||||
string key,
|
||||
string source,
|
||||
string developmentId)
|
||||
{
|
||||
return Record(NarrativeDevelopmentKind.HomeFounded, NarrativeFunction.TurningPoint,
|
||||
subject, null, "founding:" + kind + ":" + key + ":" + EventFeedUtil.SafeId(subject), source,
|
||||
subject, null, developmentId, source,
|
||||
cityKey: kind == "city" ? key : "", kingdomKey: kind == "kingdom" ? key : "",
|
||||
newState: kind, magnitude: 82f);
|
||||
}
|
||||
|
||||
public static bool War(Actor subject, Actor other, string warKey, bool ended, string source)
|
||||
{
|
||||
return WarExact(subject, other, warKey, ended, source, WarId(subject, warKey, ended));
|
||||
}
|
||||
|
||||
public static string WarId(Actor subject, string warKey, bool ended) =>
|
||||
"war:" + (warKey ?? "") + ":" + EventFeedUtil.SafeId(subject) + ":" + ended;
|
||||
|
||||
internal static bool WarExact(
|
||||
Actor subject,
|
||||
Actor other,
|
||||
string warKey,
|
||||
bool ended,
|
||||
string source,
|
||||
string developmentId)
|
||||
{
|
||||
return Record(ended ? NarrativeDevelopmentKind.WarEnded : NarrativeDevelopmentKind.WarJoined,
|
||||
ended ? NarrativeFunction.Resolution : NarrativeFunction.Escalation,
|
||||
subject, other, "war:" + warKey + ":" + EventFeedUtil.SafeId(subject) + ":" + ended,
|
||||
subject, other, developmentId,
|
||||
source, warKey: warKey, magnitude: ended ? 86f : 68f,
|
||||
outcome: ended ? "war ended" : "");
|
||||
}
|
||||
|
||||
public static bool Plot(Actor subject, Actor other, string plotKey, bool ended, string source)
|
||||
{
|
||||
return PlotExact(subject, other, plotKey, ended, source, PlotId(subject, plotKey, ended));
|
||||
}
|
||||
|
||||
public static string PlotId(Actor subject, string plotKey, bool ended) =>
|
||||
"plot:" + (plotKey ?? "") + ":" + EventFeedUtil.SafeId(subject) + ":" + ended;
|
||||
|
||||
internal static bool PlotExact(
|
||||
Actor subject,
|
||||
Actor other,
|
||||
string plotKey,
|
||||
bool ended,
|
||||
string source,
|
||||
string developmentId)
|
||||
{
|
||||
return Record(ended ? NarrativeDevelopmentKind.PlotEnded : NarrativeDevelopmentKind.PlotJoined,
|
||||
ended ? NarrativeFunction.Resolution : NarrativeFunction.Escalation,
|
||||
subject, other, "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject) + ":" + ended,
|
||||
subject, other, developmentId,
|
||||
source, plotKey: plotKey, magnitude: ended ? 78f : 64f,
|
||||
outcome: ended ? "plot ended" : "");
|
||||
}
|
||||
|
|
|
|||
56
IdleSpectator/Narrative/NarrativeEventModels.cs
Normal file
56
IdleSpectator/Narrative/NarrativeEventModels.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
namespace IdleSpectator;
|
||||
|
||||
public enum NarrativeEventKind
|
||||
{
|
||||
Unknown,
|
||||
BondFormed,
|
||||
BondBroken,
|
||||
ChildBorn,
|
||||
FriendFormed,
|
||||
Kill,
|
||||
Death,
|
||||
RoleChanged,
|
||||
HomeChanged,
|
||||
Founding,
|
||||
WarChanged,
|
||||
PlotChanged
|
||||
}
|
||||
|
||||
/// <summary>One immutable confirmed world observation, shared by all character perspectives.</summary>
|
||||
public sealed class NarrativeEventRecord
|
||||
{
|
||||
public string Id = "";
|
||||
public string DedupeKey = "";
|
||||
public NarrativeEventKind Kind;
|
||||
public long SubjectId;
|
||||
public LifeSagaIdentity Subject;
|
||||
public long OtherId;
|
||||
public LifeSagaIdentity Other;
|
||||
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 = 1f;
|
||||
}
|
||||
|
||||
public sealed class NarrativeEventReceipt
|
||||
{
|
||||
public bool IsNew;
|
||||
public string EventId = "";
|
||||
public string DevelopmentId = "";
|
||||
public NarrativePresentationModel Presentation;
|
||||
public string RejectionReason = "";
|
||||
}
|
||||
371
IdleSpectator/Narrative/NarrativeEventRecorder.cs
Normal file
371
IdleSpectator/Narrative/NarrativeEventRecorder.cs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical confirmed-observation writer. It owns raw Saga facts and semantic development
|
||||
/// projection so callers cannot independently emit the same change twice.
|
||||
/// </summary>
|
||||
public static class NarrativeEventRecorder
|
||||
{
|
||||
public static NarrativeEventReceipt BondFormed(
|
||||
Actor subject,
|
||||
Actor partner,
|
||||
string source = "set_lover")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.BondFormedId(subject, partner);
|
||||
return Record(
|
||||
NarrativeEventKind.BondFormed,
|
||||
developmentId,
|
||||
subject,
|
||||
partner,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordBondFormedFact(subject, partner, source),
|
||||
() => NarrativeDevelopmentRouter.BondFormedExact(
|
||||
subject, partner, source, developmentId),
|
||||
"set_lover");
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt BondBroken(
|
||||
Actor subject,
|
||||
Actor former,
|
||||
string source = "clear_lover")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.BondBrokenId(subject, former);
|
||||
return Record(
|
||||
NarrativeEventKind.BondBroken,
|
||||
developmentId,
|
||||
subject,
|
||||
former,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordBondBrokenFact(subject, former, source),
|
||||
() => NarrativeDevelopmentRouter.BondBrokenExact(
|
||||
subject, former, source, developmentId),
|
||||
"clear_lover");
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt ChildBorn(
|
||||
Actor parent,
|
||||
Actor child,
|
||||
string source = "add_child")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.ChildBornId(parent, child);
|
||||
return Record(
|
||||
NarrativeEventKind.ChildBorn,
|
||||
developmentId,
|
||||
parent,
|
||||
child,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordParentChildFact(parent, child, source),
|
||||
() => NarrativeDevelopmentRouter.ChildBornExact(
|
||||
parent, child, source, developmentId),
|
||||
"add_child");
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt FriendFormed(
|
||||
Actor subject,
|
||||
Actor friend,
|
||||
string source = "set_best_friend")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.FriendFormedId(subject, friend);
|
||||
return Record(
|
||||
NarrativeEventKind.FriendFormed,
|
||||
developmentId,
|
||||
subject,
|
||||
friend,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordFriendFormedFact(subject, friend, source),
|
||||
() => NarrativeDevelopmentRouter.FriendFormedExact(
|
||||
subject, friend, source, developmentId),
|
||||
eventId: "");
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt Kill(
|
||||
Actor killer,
|
||||
Actor victim,
|
||||
string source = "newKillAction")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.KillId(killer, victim);
|
||||
return Record(
|
||||
NarrativeEventKind.Kill,
|
||||
developmentId,
|
||||
killer,
|
||||
victim,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordKillFact(killer, victim, source),
|
||||
() => NarrativeDevelopmentRouter.KillExact(
|
||||
killer, victim, source, developmentId),
|
||||
eventId: "",
|
||||
outcome: "victim died",
|
||||
magnitude: 82f);
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt Death(
|
||||
Actor victim,
|
||||
Actor killer,
|
||||
string attackType,
|
||||
string source = "die")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.DeathId(victim);
|
||||
return Record(
|
||||
NarrativeEventKind.Death,
|
||||
developmentId,
|
||||
victim,
|
||||
killer,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordDeathFact(victim, killer, attackType, source),
|
||||
() => NarrativeDevelopmentRouter.DeathExact(
|
||||
victim, killer, attackType, source, developmentId),
|
||||
eventId: "",
|
||||
newState: "dead",
|
||||
outcome: attackType ?? "",
|
||||
resolved: true,
|
||||
magnitude: 100f);
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt Role(
|
||||
Actor subject,
|
||||
string role,
|
||||
bool gained,
|
||||
string source = "role")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.RoleId(subject, role, gained);
|
||||
return Record(
|
||||
NarrativeEventKind.RoleChanged,
|
||||
developmentId,
|
||||
subject,
|
||||
null,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordRoleFact(subject, role, gained, source),
|
||||
() => NarrativeDevelopmentRouter.RoleExact(
|
||||
subject, role, gained, source, developmentId),
|
||||
eventId: "",
|
||||
newState: gained ? role ?? "" : "",
|
||||
previousState: gained ? "" : role ?? "",
|
||||
resolved: !gained,
|
||||
magnitude: 78f);
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt Founding(
|
||||
Actor subject,
|
||||
string kind,
|
||||
string key,
|
||||
string source)
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.FoundingId(subject, kind, key);
|
||||
return Record(
|
||||
NarrativeEventKind.Founding,
|
||||
developmentId,
|
||||
subject,
|
||||
null,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordFoundingFact(subject, kind, key, source),
|
||||
() => NarrativeDevelopmentRouter.FoundingExact(
|
||||
subject, kind, key, source, developmentId),
|
||||
eventId: "",
|
||||
familyKey: kind == "family" ? key : "",
|
||||
cityKey: kind == "city" ? key : "",
|
||||
kingdomKey: kind == "kingdom" ? key : "",
|
||||
newState: kind ?? "",
|
||||
magnitude: 82f);
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt Home(
|
||||
Actor subject,
|
||||
bool gained,
|
||||
string source = "home")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.HomeId(subject, gained);
|
||||
return Record(
|
||||
NarrativeEventKind.HomeChanged,
|
||||
developmentId,
|
||||
subject,
|
||||
null,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordHomeFact(subject, gained, source),
|
||||
() => NarrativeDevelopmentRouter.HomeExact(
|
||||
subject, gained, source, developmentId),
|
||||
eventId: "",
|
||||
previousState: gained ? "" : "home",
|
||||
newState: gained ? "home" : "",
|
||||
outcome: gained ? "" : "home lost",
|
||||
resolved: !gained,
|
||||
magnitude: gained ? 64f : 72f);
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt War(
|
||||
Actor subject,
|
||||
Actor other,
|
||||
string warKey,
|
||||
string kingdomKey,
|
||||
bool ended,
|
||||
string source = "war",
|
||||
string note = "")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.WarId(subject, warKey, ended);
|
||||
return Record(
|
||||
NarrativeEventKind.WarChanged,
|
||||
developmentId,
|
||||
subject,
|
||||
other,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordWarFact(
|
||||
subject, warKey, kingdomKey, ended, source, other, note),
|
||||
() => NarrativeDevelopmentRouter.WarExact(
|
||||
subject, other, warKey, ended, source, developmentId),
|
||||
eventId: "",
|
||||
kingdomKey: kingdomKey,
|
||||
warKey: warKey,
|
||||
outcome: ended ? "war ended" : "",
|
||||
note: note,
|
||||
resolved: ended,
|
||||
magnitude: ended ? 86f : 68f);
|
||||
}
|
||||
|
||||
public static NarrativeEventReceipt Plot(
|
||||
Actor subject,
|
||||
Actor other,
|
||||
string plotKey,
|
||||
string plotAsset,
|
||||
bool ended,
|
||||
string source = "plot")
|
||||
{
|
||||
string developmentId = NarrativeDevelopmentRouter.PlotId(subject, plotKey, ended);
|
||||
return Record(
|
||||
NarrativeEventKind.PlotChanged,
|
||||
developmentId,
|
||||
subject,
|
||||
other,
|
||||
source,
|
||||
() => LifeSagaMemory.RecordPlotFact(
|
||||
subject, plotKey, plotAsset, ended, source, other),
|
||||
() => NarrativeDevelopmentRouter.PlotExact(
|
||||
subject, other, plotKey, ended, source, developmentId),
|
||||
eventId: "",
|
||||
plotKey: plotKey,
|
||||
outcome: ended ? "plot ended" : "",
|
||||
note: LifeSagaMemory.PlotDisplay(plotAsset),
|
||||
resolved: ended,
|
||||
magnitude: ended ? 78f : 64f);
|
||||
}
|
||||
|
||||
private static NarrativeEventReceipt Record(
|
||||
NarrativeEventKind kind,
|
||||
string developmentId,
|
||||
Actor subject,
|
||||
Actor other,
|
||||
string source,
|
||||
Func<LifeSagaFact> recordFact,
|
||||
Func<bool> recordDevelopment,
|
||||
string eventId,
|
||||
string familyKey = "",
|
||||
string cityKey = "",
|
||||
string kingdomKey = "",
|
||||
string warKey = "",
|
||||
string plotKey = "",
|
||||
string previousState = "",
|
||||
string newState = "",
|
||||
string outcome = "",
|
||||
string note = "",
|
||||
bool resolved = false,
|
||||
float magnitude = 50f)
|
||||
{
|
||||
long subjectId = EventFeedUtil.SafeId(subject);
|
||||
if (subjectId == 0 || string.IsNullOrEmpty(developmentId))
|
||||
{
|
||||
return new NarrativeEventReceipt { RejectionReason = "missing_identity" };
|
||||
}
|
||||
|
||||
string id = "event:" + developmentId;
|
||||
NarrativeEventRecord existing = NarrativeEventStore.Get(id);
|
||||
if (existing != null)
|
||||
{
|
||||
NarrativePresentationModel duplicatePresentation =
|
||||
string.IsNullOrEmpty(eventId)
|
||||
? null
|
||||
: NarrativePresentationFactory.Relationship(eventId, subject, other);
|
||||
StampPresentation(duplicatePresentation, existing.Id, existing.DevelopmentId, existing.Other);
|
||||
return new NarrativeEventReceipt
|
||||
{
|
||||
IsNew = false,
|
||||
EventId = existing.Id,
|
||||
DevelopmentId = existing.DevelopmentId,
|
||||
Presentation = duplicatePresentation
|
||||
};
|
||||
}
|
||||
|
||||
LifeSagaFact fact = recordFact != null ? recordFact() : null;
|
||||
if (fact == null)
|
||||
{
|
||||
return new NarrativeEventReceipt { RejectionReason = "fact_rejected" };
|
||||
}
|
||||
|
||||
bool newDevelopment = recordDevelopment != null && recordDevelopment();
|
||||
NarrativeDevelopment development = NarrativeStoryStore.Development(developmentId);
|
||||
LifeSagaIdentity otherSnapshot = fact.Other.Id != 0
|
||||
? fact.Other
|
||||
: LifeSagaMemory.Snapshot(other);
|
||||
var record = new NarrativeEventRecord
|
||||
{
|
||||
Id = id,
|
||||
DedupeKey = developmentId,
|
||||
Kind = kind,
|
||||
SubjectId = subjectId,
|
||||
Subject = fact.Subject.Id != 0 ? fact.Subject : LifeSagaMemory.Snapshot(subject),
|
||||
OtherId = otherSnapshot.Id,
|
||||
Other = otherSnapshot,
|
||||
DevelopmentId = developmentId,
|
||||
CorrelationKey = fact.Key ?? developmentId,
|
||||
FamilyKey = familyKey ?? "",
|
||||
CityKey = cityKey ?? "",
|
||||
KingdomKey = kingdomKey ?? "",
|
||||
WarKey = warKey ?? "",
|
||||
PlotKey = plotKey ?? "",
|
||||
PreviousState = previousState ?? "",
|
||||
NewState = newState ?? "",
|
||||
Outcome = outcome ?? "",
|
||||
Note = note ?? "",
|
||||
Resolved = resolved,
|
||||
EvidenceSource = source ?? "",
|
||||
DateLabel = fact.DateLabel ?? "",
|
||||
WorldTime = fact.WorldTime,
|
||||
ObservedAt = Time.unscaledTime,
|
||||
Magnitude = magnitude,
|
||||
Confidence = 1f
|
||||
};
|
||||
bool newEvent = NarrativeEventStore.Add(record);
|
||||
NarrativePresentationModel presentation =
|
||||
string.IsNullOrEmpty(eventId)
|
||||
? null
|
||||
: NarrativePresentationFactory.Relationship(eventId, subject, other);
|
||||
StampPresentation(presentation, record.Id, developmentId, otherSnapshot);
|
||||
return new NarrativeEventReceipt
|
||||
{
|
||||
IsNew = newEvent || newDevelopment,
|
||||
EventId = record.Id,
|
||||
DevelopmentId = development?.Id ?? developmentId,
|
||||
Presentation = presentation
|
||||
};
|
||||
}
|
||||
|
||||
private static void StampPresentation(
|
||||
NarrativePresentationModel presentation,
|
||||
string eventId,
|
||||
string developmentId,
|
||||
LifeSagaIdentity other)
|
||||
{
|
||||
if (presentation == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
presentation.EventId = eventId ?? "";
|
||||
presentation.DevelopmentId = developmentId ?? "";
|
||||
if (presentation.OtherId == 0 && other.Id != 0)
|
||||
{
|
||||
presentation.OtherId = other.Id;
|
||||
presentation.Other = other;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
IdleSpectator/Narrative/NarrativeEventStore.cs
Normal file
54
IdleSpectator/Narrative/NarrativeEventStore.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>World-session immutable observation store. Persistence is added in Phase 5.</summary>
|
||||
public static class NarrativeEventStore
|
||||
{
|
||||
private static readonly Dictionary<string, NarrativeEventRecord> Events =
|
||||
new Dictionary<string, NarrativeEventRecord>(StringComparer.Ordinal);
|
||||
|
||||
public static int Count => Events.Count;
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
Events.Clear();
|
||||
}
|
||||
|
||||
public static NarrativeEventRecord Get(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Events.TryGetValue(id, out NarrativeEventRecord record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public static bool Add(NarrativeEventRecord record)
|
||||
{
|
||||
if (record == null || string.IsNullOrEmpty(record.Id) || Events.ContainsKey(record.Id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Events[record.Id] = record;
|
||||
NarrativePersistence.MarkDirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static IEnumerable<NarrativeEventRecord> All() => Events.Values;
|
||||
|
||||
internal static bool Import(NarrativeEventRecord record)
|
||||
{
|
||||
if (record == null || string.IsNullOrEmpty(record.Id) || Events.ContainsKey(record.Id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Events[record.Id] = record;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
130
IdleSpectator/Narrative/NarrativeExposureLedger.cs
Normal file
130
IdleSpectator/Narrative/NarrativeExposureLedger.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded authored-cut ledger. Camera hold seconds do not count; only meaningful selections do.
|
||||
/// </summary>
|
||||
public static class NarrativeExposureLedger
|
||||
{
|
||||
private sealed class Exposure
|
||||
{
|
||||
public int Cuts;
|
||||
public float LastCutAt = -999f;
|
||||
public string LastSemanticFamily = "";
|
||||
public int SameFamilyRun;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<long, Exposure> Characters =
|
||||
new Dictionary<long, Exposure>();
|
||||
private static readonly Dictionary<string, Exposure> Threads =
|
||||
new Dictionary<string, Exposure>(StringComparer.Ordinal);
|
||||
private static int _maxCharacterCuts;
|
||||
private static int _maxThreadCuts;
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
Characters.Clear();
|
||||
Threads.Clear();
|
||||
_maxCharacterCuts = 0;
|
||||
_maxThreadCuts = 0;
|
||||
}
|
||||
|
||||
public static void NoteCut(
|
||||
long characterId,
|
||||
string threadId,
|
||||
string semanticFamily,
|
||||
float now)
|
||||
{
|
||||
if (characterId != 0)
|
||||
{
|
||||
Exposure exposure = Get(Characters, characterId);
|
||||
Update(exposure, semanticFamily, now);
|
||||
_maxCharacterCuts = Math.Max(_maxCharacterCuts, exposure.Cuts);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(threadId))
|
||||
{
|
||||
Exposure exposure = Get(Threads, threadId);
|
||||
Update(exposure, semanticFamily, now);
|
||||
_maxThreadCuts = Math.Max(_maxThreadCuts, exposure.Cuts);
|
||||
}
|
||||
}
|
||||
|
||||
public static float CharacterDebt(long characterId)
|
||||
{
|
||||
if (characterId == 0) return 0f;
|
||||
int cuts = Characters.TryGetValue(characterId, out Exposure exposure)
|
||||
? exposure.Cuts : 0;
|
||||
return Mathf.Min(12f, Mathf.Max(0, _maxCharacterCuts - cuts) * 1.5f);
|
||||
}
|
||||
|
||||
public static float ThreadDebt(string threadId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(threadId)) return 0f;
|
||||
int cuts = Threads.TryGetValue(threadId, out Exposure exposure)
|
||||
? exposure.Cuts : 0;
|
||||
return Mathf.Min(10f, Mathf.Max(0, _maxThreadCuts - cuts) * 1.25f);
|
||||
}
|
||||
|
||||
public static float RepetitionPenalty(long characterId, string semanticFamily)
|
||||
{
|
||||
if (characterId == 0 || string.IsNullOrEmpty(semanticFamily)
|
||||
|| !Characters.TryGetValue(characterId, out Exposure exposure)
|
||||
|| !string.Equals(
|
||||
exposure.LastSemanticFamily, semanticFamily, StringComparison.Ordinal))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return Mathf.Min(24f, exposure.SameFamilyRun * 6f);
|
||||
}
|
||||
|
||||
public static bool HarnessProbeBalance(out string detail)
|
||||
{
|
||||
Clear();
|
||||
NoteCut(1, "thread:a", "combat", 1f);
|
||||
NoteCut(1, "thread:a", "combat", 2f);
|
||||
NoteCut(1, "thread:a", "combat", 3f);
|
||||
NoteCut(2, "thread:b", "lineage", 4f);
|
||||
float underCovered = CharacterDebt(2);
|
||||
float prolific = CharacterDebt(1);
|
||||
float repetition = RepetitionPenalty(1, "combat");
|
||||
float diverse = RepetitionPenalty(1, "lineage");
|
||||
bool ok = underCovered > prolific && repetition > 0f && diverse == 0f;
|
||||
detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0")
|
||||
+ " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0")
|
||||
+ " pass=" + ok;
|
||||
Clear();
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static Exposure Get<TKey>(Dictionary<TKey, Exposure> map, TKey key)
|
||||
{
|
||||
if (!map.TryGetValue(key, out Exposure exposure))
|
||||
{
|
||||
exposure = new Exposure();
|
||||
map[key] = exposure;
|
||||
}
|
||||
|
||||
return exposure;
|
||||
}
|
||||
|
||||
private static void Update(Exposure exposure, string family, float now)
|
||||
{
|
||||
exposure.Cuts++;
|
||||
exposure.LastCutAt = now;
|
||||
if (!string.IsNullOrEmpty(family)
|
||||
&& string.Equals(exposure.LastSemanticFamily, family, StringComparison.Ordinal))
|
||||
{
|
||||
exposure.SameFamilyRun++;
|
||||
}
|
||||
else
|
||||
{
|
||||
exposure.LastSemanticFamily = family ?? "";
|
||||
exposure.SameFamilyRun = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -230,6 +230,19 @@ public enum EpisodePurpose
|
|||
Reintroduce
|
||||
}
|
||||
|
||||
public enum EpisodeShotRole
|
||||
{
|
||||
Unknown,
|
||||
Establish,
|
||||
Development,
|
||||
Pressure,
|
||||
TurningPoint,
|
||||
Payoff,
|
||||
Consequence,
|
||||
Reintroduction,
|
||||
WorldContext
|
||||
}
|
||||
|
||||
public sealed class EpisodePlan
|
||||
{
|
||||
public string ThreadId = "";
|
||||
|
|
@ -242,8 +255,23 @@ public sealed class EpisodePlan
|
|||
public float LastProgressAt;
|
||||
public float InterruptedAt = -1f;
|
||||
public string LastShotKey = "";
|
||||
public long LastSubjectId;
|
||||
public int RepeatedSubjectCount;
|
||||
public int CombatShotCount;
|
||||
public bool GoalMet;
|
||||
public string CompletionReason = "";
|
||||
public readonly List<EpisodeShotRole> CoveredRoles = new List<EpisodeShotRole>(8);
|
||||
public readonly List<EpisodeShotRole> PendingRoles = new List<EpisodeShotRole>(8);
|
||||
public readonly List<string> RecentSemanticKeys = new List<string>(10);
|
||||
|
||||
public bool HasCovered(EpisodeShotRole role) => CoveredRoles.Contains(role);
|
||||
|
||||
public void Cover(EpisodeShotRole role)
|
||||
{
|
||||
if (role == EpisodeShotRole.Unknown || role == EpisodeShotRole.WorldContext) return;
|
||||
if (!CoveredRoles.Contains(role)) CoveredRoles.Add(role);
|
||||
PendingRoles.Remove(role);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NarrativeBeatModel
|
||||
|
|
|
|||
700
IdleSpectator/Narrative/NarrativePersistence.cs
Normal file
700
IdleSpectator/Narrative/NarrativePersistence.cs
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
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 = "";
|
||||
}
|
||||
378
IdleSpectator/Narrative/NarrativePersistenceMapper.cs
Normal file
378
IdleSpectator/Narrative/NarrativePersistenceMapper.cs
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
internal static class NarrativePersistenceMapper
|
||||
{
|
||||
public static NarrativeEventRecordDto ToDto(NarrativeEventRecord value) =>
|
||||
new NarrativeEventRecordDto
|
||||
{
|
||||
id = value?.Id ?? "",
|
||||
dedupeKey = value?.DedupeKey ?? "",
|
||||
kind = (int)(value?.Kind ?? NarrativeEventKind.Unknown),
|
||||
subjectId = value?.SubjectId ?? 0,
|
||||
subject = ToDto(value?.Subject ?? default),
|
||||
otherId = value?.OtherId ?? 0,
|
||||
other = ToDto(value?.Other ?? default),
|
||||
developmentId = value?.DevelopmentId ?? "",
|
||||
correlationKey = value?.CorrelationKey ?? "",
|
||||
familyKey = value?.FamilyKey ?? "",
|
||||
cityKey = value?.CityKey ?? "",
|
||||
kingdomKey = value?.KingdomKey ?? "",
|
||||
warKey = value?.WarKey ?? "",
|
||||
plotKey = value?.PlotKey ?? "",
|
||||
previousState = value?.PreviousState ?? "",
|
||||
newState = value?.NewState ?? "",
|
||||
outcome = value?.Outcome ?? "",
|
||||
note = value?.Note ?? "",
|
||||
resolved = value?.Resolved ?? false,
|
||||
evidenceSource = value?.EvidenceSource ?? "",
|
||||
dateLabel = value?.DateLabel ?? "",
|
||||
worldTime = value?.WorldTime ?? 0d,
|
||||
observedAt = value?.ObservedAt ?? 0f,
|
||||
magnitude = value?.Magnitude ?? 0f,
|
||||
confidence = value?.Confidence ?? 0f
|
||||
};
|
||||
|
||||
public static NarrativeEventRecord FromDto(NarrativeEventRecordDto value) =>
|
||||
value == null ? null : new NarrativeEventRecord
|
||||
{
|
||||
Id = value.id ?? "",
|
||||
DedupeKey = value.dedupeKey ?? "",
|
||||
Kind = (NarrativeEventKind)value.kind,
|
||||
SubjectId = value.subjectId,
|
||||
Subject = FromDto(value.subject),
|
||||
OtherId = value.otherId,
|
||||
Other = FromDto(value.other),
|
||||
DevelopmentId = value.developmentId ?? "",
|
||||
CorrelationKey = value.correlationKey ?? "",
|
||||
FamilyKey = value.familyKey ?? "",
|
||||
CityKey = value.cityKey ?? "",
|
||||
KingdomKey = value.kingdomKey ?? "",
|
||||
WarKey = value.warKey ?? "",
|
||||
PlotKey = value.plotKey ?? "",
|
||||
PreviousState = value.previousState ?? "",
|
||||
NewState = value.newState ?? "",
|
||||
Outcome = value.outcome ?? "",
|
||||
Note = value.note ?? "",
|
||||
Resolved = value.resolved,
|
||||
EvidenceSource = value.evidenceSource ?? "",
|
||||
DateLabel = value.dateLabel ?? "",
|
||||
WorldTime = value.worldTime,
|
||||
ObservedAt = value.observedAt,
|
||||
Magnitude = value.magnitude,
|
||||
Confidence = value.confidence
|
||||
};
|
||||
|
||||
public static NarrativeDevelopmentDto ToDto(NarrativeDevelopment value)
|
||||
{
|
||||
var dto = new NarrativeDevelopmentDto
|
||||
{
|
||||
id = value?.Id ?? "",
|
||||
kind = (int)(value?.Kind ?? NarrativeDevelopmentKind.Unknown),
|
||||
function = (int)(value?.Function ?? NarrativeFunction.Noise),
|
||||
subjectId = value?.SubjectId ?? 0,
|
||||
subject = ToDto(value?.Subject ?? default),
|
||||
otherId = value?.OtherId ?? 0,
|
||||
other = ToDto(value?.Other ?? default),
|
||||
correlationKey = value?.CorrelationKey ?? "",
|
||||
familyKey = value?.FamilyKey ?? "",
|
||||
cityKey = value?.CityKey ?? "",
|
||||
kingdomKey = value?.KingdomKey ?? "",
|
||||
warKey = value?.WarKey ?? "",
|
||||
plotKey = value?.PlotKey ?? "",
|
||||
previousState = value?.PreviousState ?? "",
|
||||
newState = value?.NewState ?? "",
|
||||
outcome = value?.Outcome ?? "",
|
||||
evidenceSource = value?.EvidenceSource ?? "",
|
||||
dateLabel = value?.DateLabel ?? "",
|
||||
occurredAt = value?.OccurredAt ?? 0f,
|
||||
magnitude = value?.Magnitude ?? 0f,
|
||||
confidence = value?.Confidence ?? 0f,
|
||||
presentable = value?.Presentable ?? false,
|
||||
isNewStateChange = value?.IsNewStateChange ?? false
|
||||
};
|
||||
if (value != null) dto.otherIds = value.OtherIds.ToArray();
|
||||
return dto;
|
||||
}
|
||||
|
||||
public static NarrativeDevelopment FromDto(
|
||||
NarrativeDevelopmentDto value,
|
||||
double savedWorldTime,
|
||||
double currentWorldTime)
|
||||
{
|
||||
if (value == null) return null;
|
||||
var result = new NarrativeDevelopment
|
||||
{
|
||||
Id = value.id ?? "",
|
||||
Kind = (NarrativeDevelopmentKind)value.kind,
|
||||
Function = (NarrativeFunction)value.function,
|
||||
SubjectId = value.subjectId,
|
||||
Subject = FromDto(value.subject),
|
||||
OtherId = value.otherId,
|
||||
Other = FromDto(value.other),
|
||||
CorrelationKey = value.correlationKey ?? "",
|
||||
FamilyKey = value.familyKey ?? "",
|
||||
CityKey = value.cityKey ?? "",
|
||||
KingdomKey = value.kingdomKey ?? "",
|
||||
WarKey = value.warKey ?? "",
|
||||
PlotKey = value.plotKey ?? "",
|
||||
PreviousState = value.previousState ?? "",
|
||||
NewState = value.newState ?? "",
|
||||
Outcome = value.outcome ?? "",
|
||||
EvidenceSource = value.evidenceSource ?? "",
|
||||
DateLabel = value.dateLabel ?? "",
|
||||
OccurredAt = NarrativePersistence.Rebase(
|
||||
value.occurredAt, savedWorldTime, currentWorldTime),
|
||||
Magnitude = value.magnitude,
|
||||
Confidence = value.confidence,
|
||||
Presentable = value.presentable,
|
||||
IsNewStateChange = false
|
||||
};
|
||||
if (value.otherIds != null) result.OtherIds.AddRange(value.otherIds);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static NarrativeThreadDto ToDto(NarrativeThread value)
|
||||
{
|
||||
var dto = new NarrativeThreadDto
|
||||
{
|
||||
id = value?.Id ?? "",
|
||||
kind = (int)(value?.Kind ?? NarrativeThreadKind.Unknown),
|
||||
protagonistId = value?.ProtagonistId ?? 0,
|
||||
anchorKey = value?.AnchorKey ?? "",
|
||||
openedByDevelopmentId = value?.OpenedByDevelopmentId ?? "",
|
||||
latestMeaningfulChangeId = value?.LatestMeaningfulChangeId ?? "",
|
||||
centralQuestion = value?.CentralQuestion ?? "",
|
||||
pressureEvidence = value?.PressureEvidence ?? "",
|
||||
resolution = value?.Resolution ?? "",
|
||||
phase = (int)(value?.Phase ?? NarrativePhase.Setup),
|
||||
status = (int)(value?.Status ?? NarrativeThreadStatus.Abandoned),
|
||||
openedAt = value?.OpenedAt ?? 0f,
|
||||
updatedAt = value?.UpdatedAt ?? 0f,
|
||||
momentum = value?.Momentum ?? 0f,
|
||||
urgency = value?.Urgency ?? 0f,
|
||||
coherence = value?.Coherence ?? 0f,
|
||||
novelty = value?.Novelty ?? 0f,
|
||||
coverageDebt = value?.CoverageDebt ?? 0f
|
||||
};
|
||||
if (value != null)
|
||||
{
|
||||
var cast = new List<NarrativeCastRoleDto>(value.Cast.Count);
|
||||
for (int i = 0; i < value.Cast.Count; i++)
|
||||
{
|
||||
NarrativeCastRole role = value.Cast[i];
|
||||
if (role != null)
|
||||
cast.Add(new NarrativeCastRoleDto
|
||||
{
|
||||
characterId = role.CharacterId,
|
||||
role = role.Role ?? ""
|
||||
});
|
||||
}
|
||||
dto.cast = cast.ToArray();
|
||||
dto.developmentIds = value.DevelopmentIds.ToArray();
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
public static NarrativeThread FromDto(
|
||||
NarrativeThreadDto value,
|
||||
double savedWorldTime,
|
||||
double currentWorldTime)
|
||||
{
|
||||
if (value == null) return null;
|
||||
var result = new NarrativeThread
|
||||
{
|
||||
Id = value.id ?? "",
|
||||
Kind = (NarrativeThreadKind)value.kind,
|
||||
ProtagonistId = value.protagonistId,
|
||||
AnchorKey = value.anchorKey ?? "",
|
||||
OpenedByDevelopmentId = value.openedByDevelopmentId ?? "",
|
||||
LatestMeaningfulChangeId = value.latestMeaningfulChangeId ?? "",
|
||||
CentralQuestion = value.centralQuestion ?? "",
|
||||
PressureEvidence = value.pressureEvidence ?? "",
|
||||
Resolution = value.resolution ?? "",
|
||||
Phase = (NarrativePhase)value.phase,
|
||||
Status = (NarrativeThreadStatus)value.status,
|
||||
OpenedAt = NarrativePersistence.Rebase(
|
||||
value.openedAt, savedWorldTime, currentWorldTime),
|
||||
UpdatedAt = NarrativePersistence.Rebase(
|
||||
value.updatedAt, savedWorldTime, currentWorldTime),
|
||||
Momentum = value.momentum,
|
||||
Urgency = value.urgency,
|
||||
Coherence = value.coherence,
|
||||
Novelty = value.novelty,
|
||||
CoverageDebt = value.coverageDebt
|
||||
};
|
||||
if (value.cast != null)
|
||||
{
|
||||
for (int i = 0; i < value.cast.Length; i++)
|
||||
{
|
||||
NarrativeCastRoleDto role = value.cast[i];
|
||||
if (role != null)
|
||||
result.Cast.Add(new NarrativeCastRole
|
||||
{
|
||||
CharacterId = role.characterId,
|
||||
Role = role.role ?? ""
|
||||
});
|
||||
}
|
||||
}
|
||||
if (value.developmentIds != null) result.DevelopmentIds.AddRange(value.developmentIds);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static CharacterConsequenceDto ToDto(CharacterConsequence value) =>
|
||||
new CharacterConsequenceDto
|
||||
{
|
||||
id = value?.Id ?? "",
|
||||
characterId = value?.CharacterId ?? 0,
|
||||
threadId = value?.ThreadId ?? "",
|
||||
developmentId = value?.DevelopmentId ?? "",
|
||||
kind = (int)(value?.Kind ?? CharacterConsequenceKind.Unknown),
|
||||
role = value?.Role ?? "",
|
||||
otherId = value?.OtherId ?? 0,
|
||||
other = ToDto(value?.Other ?? default),
|
||||
context = value?.Context ?? "",
|
||||
outcome = value?.Outcome ?? "",
|
||||
dateLabel = value?.DateLabel ?? "",
|
||||
occurredAt = value?.OccurredAt ?? 0f,
|
||||
importance = value?.Importance ?? 0f,
|
||||
confidence = value?.Confidence ?? 0f
|
||||
};
|
||||
|
||||
public static CharacterConsequence FromDto(
|
||||
CharacterConsequenceDto value,
|
||||
double savedWorldTime,
|
||||
double currentWorldTime) =>
|
||||
value == null ? null : new CharacterConsequence
|
||||
{
|
||||
Id = value.id ?? "",
|
||||
CharacterId = value.characterId,
|
||||
ThreadId = value.threadId ?? "",
|
||||
DevelopmentId = value.developmentId ?? "",
|
||||
Kind = (CharacterConsequenceKind)value.kind,
|
||||
Role = value.role ?? "",
|
||||
OtherId = value.otherId,
|
||||
Other = FromDto(value.other),
|
||||
Context = value.context ?? "",
|
||||
Outcome = value.outcome ?? "",
|
||||
DateLabel = value.dateLabel ?? "",
|
||||
OccurredAt = NarrativePersistence.Rebase(
|
||||
value.occurredAt, savedWorldTime, currentWorldTime),
|
||||
Importance = value.importance,
|
||||
Confidence = value.confidence
|
||||
};
|
||||
|
||||
public static CharacterStoryStateDto ToDto(CharacterStoryState value)
|
||||
{
|
||||
var dto = new CharacterStoryStateDto
|
||||
{
|
||||
characterId = value?.CharacterId ?? 0,
|
||||
identity = ToDto(value?.Identity ?? default),
|
||||
storyMomentum = value?.StoryMomentum ?? 0f,
|
||||
storyPotential = value?.StoryPotential ?? 0f,
|
||||
lastMeaningfulChangeAt = value?.LastMeaningfulChangeAt ?? 0f
|
||||
};
|
||||
if (value != null)
|
||||
{
|
||||
dto.openThreadIds = value.OpenThreadIds.ToArray();
|
||||
dto.resolvedThreadIds = value.ResolvedThreadIds.ToArray();
|
||||
dto.recentDevelopmentIds = value.RecentDevelopmentIds.ToArray();
|
||||
dto.consequenceIds = value.ConsequenceIds.ToArray();
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
public static CharacterStoryState FromDto(
|
||||
CharacterStoryStateDto value,
|
||||
double savedWorldTime,
|
||||
double currentWorldTime)
|
||||
{
|
||||
if (value == null) return null;
|
||||
var result = new CharacterStoryState
|
||||
{
|
||||
CharacterId = value.characterId,
|
||||
Identity = FromDto(value.identity),
|
||||
StoryMomentum = value.storyMomentum,
|
||||
StoryPotential = value.storyPotential,
|
||||
LastMeaningfulChangeAt = NarrativePersistence.Rebase(
|
||||
value.lastMeaningfulChangeAt, savedWorldTime, currentWorldTime)
|
||||
};
|
||||
Add(result.OpenThreadIds, value.openThreadIds);
|
||||
Add(result.ResolvedThreadIds, value.resolvedThreadIds);
|
||||
Add(result.RecentDevelopmentIds, value.recentDevelopmentIds);
|
||||
Add(result.ConsequenceIds, value.consequenceIds);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static LifeSagaFactDto ToDto(LifeSagaFact value) =>
|
||||
new LifeSagaFactDto
|
||||
{
|
||||
key = value?.Key ?? "",
|
||||
kind = (int)(value?.Kind ?? LifeSagaFactKind.Unknown),
|
||||
subjectId = value?.SubjectId ?? 0,
|
||||
otherId = value?.OtherId ?? 0,
|
||||
subject = ToDto(value?.Subject ?? default),
|
||||
other = ToDto(value?.Other ?? default),
|
||||
at = value?.At ?? 0f,
|
||||
worldTime = value?.WorldTime ?? 0d,
|
||||
dateLabel = value?.DateLabel ?? "",
|
||||
strength = value?.Strength ?? 0f,
|
||||
provenance = value?.Provenance ?? "",
|
||||
kingdomKey = value?.KingdomKey ?? "",
|
||||
cityKey = value?.CityKey ?? "",
|
||||
clanKey = value?.ClanKey ?? "",
|
||||
familyKey = value?.FamilyKey ?? "",
|
||||
warKey = value?.WarKey ?? "",
|
||||
plotKey = value?.PlotKey ?? "",
|
||||
resolved = value?.Resolved ?? false,
|
||||
note = value?.Note ?? "",
|
||||
mcCrossover = value?.McCrossover ?? false
|
||||
};
|
||||
|
||||
public static LifeSagaFact FromDto(LifeSagaFactDto value) =>
|
||||
value == null ? null : new LifeSagaFact
|
||||
{
|
||||
Key = value.key ?? "",
|
||||
Kind = (LifeSagaFactKind)value.kind,
|
||||
SubjectId = value.subjectId,
|
||||
OtherId = value.otherId,
|
||||
Subject = FromDto(value.subject),
|
||||
Other = FromDto(value.other),
|
||||
At = NarrativePersistence.Rebase(value.at, value.worldTime, value.worldTime),
|
||||
WorldTime = value.worldTime,
|
||||
DateLabel = value.dateLabel ?? "",
|
||||
Strength = value.strength,
|
||||
Provenance = value.provenance ?? "",
|
||||
KingdomKey = value.kingdomKey ?? "",
|
||||
CityKey = value.cityKey ?? "",
|
||||
ClanKey = value.clanKey ?? "",
|
||||
FamilyKey = value.familyKey ?? "",
|
||||
WarKey = value.warKey ?? "",
|
||||
PlotKey = value.plotKey ?? "",
|
||||
Resolved = value.resolved,
|
||||
Note = value.note ?? "",
|
||||
McCrossover = value.mcCrossover
|
||||
};
|
||||
|
||||
private static NarrativeIdentityDto ToDto(LifeSagaIdentity value) =>
|
||||
new NarrativeIdentityDto
|
||||
{
|
||||
id = value.Id,
|
||||
name = value.Name ?? "",
|
||||
speciesId = value.SpeciesId ?? ""
|
||||
};
|
||||
|
||||
private static LifeSagaIdentity FromDto(NarrativeIdentityDto value) =>
|
||||
value == null ? default : new LifeSagaIdentity
|
||||
{
|
||||
Id = value.id,
|
||||
Name = value.name ?? "",
|
||||
SpeciesId = value.speciesId ?? ""
|
||||
};
|
||||
|
||||
private static void Add(List<string> target, string[] source)
|
||||
{
|
||||
if (source != null) target.AddRange(source);
|
||||
}
|
||||
}
|
||||
754
IdleSpectator/Narrative/NarrativePresentation.cs
Normal file
754
IdleSpectator/Narrative/NarrativePresentation.cs
Normal file
|
|
@ -0,0 +1,754 @@
|
|||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic presentation kind. These values describe observed meaning, never final player-facing
|
||||
/// English.
|
||||
/// </summary>
|
||||
public enum NarrativePresentationKind
|
||||
{
|
||||
Unknown,
|
||||
BondFormed,
|
||||
BondBroken,
|
||||
SeekingLover,
|
||||
FriendFormed,
|
||||
ParentWelcomesChild,
|
||||
ChildBorn,
|
||||
FamilyFormed,
|
||||
FamilyEnded,
|
||||
FamilyPackFormed,
|
||||
FamilyPackJoined,
|
||||
FamilyPackLeft,
|
||||
GiftGiven,
|
||||
GiftReceived,
|
||||
SharedIntimacy,
|
||||
Conversation,
|
||||
Gossip,
|
||||
GrievesChild,
|
||||
GrievesLover,
|
||||
GrievesFriend,
|
||||
GrievesKin,
|
||||
AuthoredObservation,
|
||||
EnsembleObservation,
|
||||
StatusObservation,
|
||||
WorldObservation
|
||||
}
|
||||
|
||||
public enum NarrativeRelationRole
|
||||
{
|
||||
None,
|
||||
Lover,
|
||||
FormerLover,
|
||||
Friend,
|
||||
Parent,
|
||||
Child,
|
||||
Kin,
|
||||
GiftPartner,
|
||||
ConversationPartner
|
||||
}
|
||||
|
||||
/// <summary>Complete semantic input for one orange Beat. Contains no final sentence.</summary>
|
||||
public sealed class NarrativePresentationModel
|
||||
{
|
||||
public string EventId = "";
|
||||
public string DevelopmentId = "";
|
||||
public NarrativePresentationKind Kind;
|
||||
public NarrativeDevelopmentKind DevelopmentKind;
|
||||
public long PerspectiveId;
|
||||
public LifeSagaIdentity Perspective;
|
||||
public long OtherId;
|
||||
public LifeSagaIdentity Other;
|
||||
public NarrativeRelationRole RelationRole;
|
||||
public int Ordinal;
|
||||
public int Count;
|
||||
public string Change = "";
|
||||
public string Outcome = "";
|
||||
/// <summary>Catalog-authored observation clause used only by the safe typed fallback.</summary>
|
||||
public string AuthoredClause = "";
|
||||
public string ActionId = "";
|
||||
public string Phase = "";
|
||||
public string EvidenceSource = "";
|
||||
public float Confidence = 1f;
|
||||
|
||||
public NarrativePresentationModel Clone()
|
||||
{
|
||||
return new NarrativePresentationModel
|
||||
{
|
||||
EventId = EventId,
|
||||
DevelopmentId = DevelopmentId,
|
||||
Kind = Kind,
|
||||
DevelopmentKind = DevelopmentKind,
|
||||
PerspectiveId = PerspectiveId,
|
||||
Perspective = Perspective,
|
||||
OtherId = OtherId,
|
||||
Other = Other,
|
||||
RelationRole = RelationRole,
|
||||
Ordinal = Ordinal,
|
||||
Count = Count,
|
||||
Change = Change,
|
||||
Outcome = Outcome,
|
||||
AuthoredClause = AuthoredClause,
|
||||
ActionId = ActionId,
|
||||
Phase = Phase,
|
||||
EvidenceSource = EvidenceSource,
|
||||
Confidence = Confidence
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class NarrativePresentationFactory
|
||||
{
|
||||
public static NarrativePresentationModel Relationship(string eventId, Actor subject, Actor related)
|
||||
{
|
||||
if (subject == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string id = (eventId ?? "").Trim().ToLowerInvariant();
|
||||
NarrativePresentationKind presentationKind;
|
||||
NarrativeDevelopmentKind developmentKind = NarrativeDevelopmentKind.Unknown;
|
||||
NarrativeRelationRole relation = NarrativeRelationRole.None;
|
||||
switch (id)
|
||||
{
|
||||
case "set_lover":
|
||||
presentationKind = NarrativePresentationKind.BondFormed;
|
||||
developmentKind = NarrativeDevelopmentKind.BondFormed;
|
||||
relation = NarrativeRelationRole.Lover;
|
||||
break;
|
||||
case "clear_lover":
|
||||
presentationKind = NarrativePresentationKind.BondBroken;
|
||||
developmentKind = NarrativeDevelopmentKind.BondBroken;
|
||||
relation = NarrativeRelationRole.FormerLover;
|
||||
break;
|
||||
case "find_lover":
|
||||
presentationKind = NarrativePresentationKind.SeekingLover;
|
||||
break;
|
||||
case "add_child":
|
||||
presentationKind = NarrativePresentationKind.ParentWelcomesChild;
|
||||
developmentKind = NarrativeDevelopmentKind.ChildBorn;
|
||||
relation = NarrativeRelationRole.Child;
|
||||
break;
|
||||
case "baby_created":
|
||||
presentationKind = NarrativePresentationKind.ChildBorn;
|
||||
developmentKind = NarrativeDevelopmentKind.ChildBorn;
|
||||
relation = NarrativeRelationRole.Parent;
|
||||
if (related == null)
|
||||
{
|
||||
related = FirstParent(subject);
|
||||
}
|
||||
break;
|
||||
case "new_family":
|
||||
presentationKind = NarrativePresentationKind.FamilyFormed;
|
||||
break;
|
||||
case "family_removed":
|
||||
presentationKind = NarrativePresentationKind.FamilyEnded;
|
||||
break;
|
||||
case "family_group_new":
|
||||
presentationKind = NarrativePresentationKind.FamilyPackFormed;
|
||||
relation = NarrativeRelationRole.Kin;
|
||||
break;
|
||||
case "family_group_join":
|
||||
presentationKind = NarrativePresentationKind.FamilyPackJoined;
|
||||
relation = NarrativeRelationRole.Kin;
|
||||
break;
|
||||
case "family_group_leave":
|
||||
presentationKind = NarrativePresentationKind.FamilyPackLeft;
|
||||
relation = NarrativeRelationRole.Kin;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
long perspectiveId = EventFeedUtil.SafeId(subject);
|
||||
LifeSagaIdentity otherSnapshot = LifeSagaMemory.Snapshot(related);
|
||||
NarrativeDevelopment development = developmentKind == NarrativeDevelopmentKind.Unknown
|
||||
? null
|
||||
: MatchingDevelopment(perspectiveId, developmentKind, otherSnapshot.Id);
|
||||
if (otherSnapshot.Id == 0 && development != null && development.OtherId != 0)
|
||||
{
|
||||
otherSnapshot = development.Other;
|
||||
}
|
||||
|
||||
int ordinal = 0;
|
||||
if (presentationKind == NarrativePresentationKind.ParentWelcomesChild)
|
||||
{
|
||||
ordinal = NarrativeStoryStore.CountConsequences(
|
||||
perspectiveId, CharacterConsequenceKind.BecameParent);
|
||||
}
|
||||
|
||||
string developmentId = development?.Id ?? "";
|
||||
return new NarrativePresentationModel
|
||||
{
|
||||
EventId = string.IsNullOrEmpty(developmentId)
|
||||
? "observed:" + id + ":" + perspectiveId + ":" + otherSnapshot.Id
|
||||
: developmentId,
|
||||
DevelopmentId = developmentId,
|
||||
Kind = presentationKind,
|
||||
DevelopmentKind = developmentKind,
|
||||
PerspectiveId = perspectiveId,
|
||||
Perspective = LifeSagaMemory.Snapshot(subject),
|
||||
OtherId = otherSnapshot.Id,
|
||||
Other = otherSnapshot,
|
||||
RelationRole = relation,
|
||||
Ordinal = ordinal,
|
||||
EvidenceSource = "relationship:" + id,
|
||||
Confidence = 1f
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed presentation for relationship-bearing happiness signals. Ambient effects deliberately
|
||||
/// stay on the compatibility path until their semantic catalog migration.
|
||||
/// </summary>
|
||||
public static NarrativePresentationModel Happiness(
|
||||
HappinessOccurrence occurrence,
|
||||
Actor subject,
|
||||
Actor related)
|
||||
{
|
||||
if (occurrence == null || subject == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string id = (occurrence.EffectId ?? "").Trim().ToLowerInvariant();
|
||||
NarrativePresentationKind presentationKind;
|
||||
NarrativeDevelopmentKind developmentKind = NarrativeDevelopmentKind.Unknown;
|
||||
NarrativeRelationRole relation = NarrativeRelationRole.None;
|
||||
switch (id)
|
||||
{
|
||||
case "fallen_in_love":
|
||||
presentationKind = NarrativePresentationKind.BondFormed;
|
||||
developmentKind = NarrativeDevelopmentKind.BondFormed;
|
||||
relation = NarrativeRelationRole.Lover;
|
||||
break;
|
||||
case "just_made_friend":
|
||||
presentationKind = NarrativePresentationKind.FriendFormed;
|
||||
developmentKind = NarrativeDevelopmentKind.FriendFormed;
|
||||
relation = NarrativeRelationRole.Friend;
|
||||
break;
|
||||
case "just_had_child":
|
||||
presentationKind = NarrativePresentationKind.ParentWelcomesChild;
|
||||
developmentKind = NarrativeDevelopmentKind.ChildBorn;
|
||||
relation = NarrativeRelationRole.Child;
|
||||
break;
|
||||
case "just_gave_gift":
|
||||
presentationKind = NarrativePresentationKind.GiftGiven;
|
||||
relation = NarrativeRelationRole.GiftPartner;
|
||||
break;
|
||||
case "just_received_gift":
|
||||
presentationKind = NarrativePresentationKind.GiftReceived;
|
||||
relation = NarrativeRelationRole.GiftPartner;
|
||||
break;
|
||||
case "just_kissed":
|
||||
presentationKind = NarrativePresentationKind.SharedIntimacy;
|
||||
relation = NarrativeRelationRole.Lover;
|
||||
break;
|
||||
case "just_talked":
|
||||
presentationKind = NarrativePresentationKind.Conversation;
|
||||
relation = NarrativeRelationRole.ConversationPartner;
|
||||
break;
|
||||
case "just_talked_gossip":
|
||||
presentationKind = NarrativePresentationKind.Gossip;
|
||||
relation = NarrativeRelationRole.ConversationPartner;
|
||||
break;
|
||||
case "death_child":
|
||||
presentationKind = NarrativePresentationKind.GrievesChild;
|
||||
relation = NarrativeRelationRole.Child;
|
||||
break;
|
||||
case "death_lover":
|
||||
presentationKind = NarrativePresentationKind.GrievesLover;
|
||||
relation = NarrativeRelationRole.FormerLover;
|
||||
break;
|
||||
case "death_best_friend":
|
||||
presentationKind = NarrativePresentationKind.GrievesFriend;
|
||||
relation = NarrativeRelationRole.Friend;
|
||||
break;
|
||||
case "death_family_member":
|
||||
presentationKind = NarrativePresentationKind.GrievesKin;
|
||||
relation = NarrativeRelationRole.Kin;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
long perspectiveId = EventFeedUtil.SafeId(subject);
|
||||
LifeSagaIdentity otherSnapshot = LifeSagaMemory.Snapshot(related);
|
||||
if (otherSnapshot.Id == 0 && occurrence.RelatedId != 0)
|
||||
{
|
||||
otherSnapshot = new LifeSagaIdentity
|
||||
{
|
||||
Id = occurrence.RelatedId,
|
||||
Name = occurrence.RelatedName ?? "",
|
||||
SpeciesId = occurrence.RelatedSpecies ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
NarrativeDevelopment development = developmentKind == NarrativeDevelopmentKind.Unknown
|
||||
? null
|
||||
: MatchingDevelopment(perspectiveId, developmentKind, otherSnapshot.Id);
|
||||
if (otherSnapshot.Id == 0 && development != null && development.OtherId != 0)
|
||||
{
|
||||
otherSnapshot = development.Other;
|
||||
}
|
||||
|
||||
int ordinal = presentationKind == NarrativePresentationKind.ParentWelcomesChild
|
||||
? NarrativeStoryStore.CountConsequences(
|
||||
perspectiveId, CharacterConsequenceKind.BecameParent)
|
||||
: 0;
|
||||
string developmentId = development?.Id ?? "";
|
||||
string eventId = !string.IsNullOrEmpty(developmentId)
|
||||
? developmentId
|
||||
: "happiness:" + id + ":" + perspectiveId + ":" + otherSnapshot.Id
|
||||
+ ":" + (occurrence.CorrelationKey ?? "");
|
||||
return new NarrativePresentationModel
|
||||
{
|
||||
EventId = eventId,
|
||||
DevelopmentId = developmentId,
|
||||
Kind = presentationKind,
|
||||
DevelopmentKind = developmentKind,
|
||||
PerspectiveId = perspectiveId,
|
||||
Perspective = LifeSagaMemory.Snapshot(subject),
|
||||
OtherId = otherSnapshot.Id,
|
||||
Other = otherSnapshot,
|
||||
RelationRole = relation,
|
||||
Ordinal = ordinal,
|
||||
EvidenceSource = "happiness:" + id,
|
||||
Confidence = 1f
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safe typed envelope for catalog/ensemble events that do not create a durable character
|
||||
/// development. The renderer owns subject removal and final HUD hygiene.
|
||||
/// </summary>
|
||||
public static NarrativePresentationModel Candidate(
|
||||
string key,
|
||||
string source,
|
||||
string actionId,
|
||||
string authoredClause,
|
||||
Actor subject,
|
||||
Actor related)
|
||||
{
|
||||
string normalizedSource = (source ?? "").Trim().ToLowerInvariant();
|
||||
NarrativePresentationKind kind;
|
||||
if (normalizedSource == "status")
|
||||
{
|
||||
kind = NarrativePresentationKind.StatusObservation;
|
||||
}
|
||||
else if (normalizedSource == "worldlog"
|
||||
|| normalizedSource == "era"
|
||||
|| normalizedSource == "worldlaw"
|
||||
|| normalizedSource == "power"
|
||||
|| normalizedSource == "biome"
|
||||
|| normalizedSource == "earthquake")
|
||||
{
|
||||
kind = NarrativePresentationKind.WorldObservation;
|
||||
}
|
||||
else if ((actionId ?? "").StartsWith("live_", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedSource == "combat"
|
||||
|| normalizedSource == "war"
|
||||
|| normalizedSource == "plot")
|
||||
{
|
||||
kind = NarrativePresentationKind.EnsembleObservation;
|
||||
}
|
||||
else
|
||||
{
|
||||
kind = NarrativePresentationKind.AuthoredObservation;
|
||||
}
|
||||
|
||||
return new NarrativePresentationModel
|
||||
{
|
||||
EventId = string.IsNullOrEmpty(key) ? "observed:" + normalizedSource : key,
|
||||
Kind = kind,
|
||||
PerspectiveId = EventFeedUtil.SafeId(subject),
|
||||
Perspective = LifeSagaMemory.Snapshot(subject),
|
||||
OtherId = EventFeedUtil.SafeId(related),
|
||||
Other = LifeSagaMemory.Snapshot(related),
|
||||
AuthoredClause = authoredClause ?? "",
|
||||
ActionId = actionId ?? "",
|
||||
Phase = normalizedSource,
|
||||
EvidenceSource = "catalog:" + normalizedSource,
|
||||
Confidence = 1f
|
||||
};
|
||||
}
|
||||
|
||||
private static NarrativeDevelopment MatchingDevelopment(
|
||||
long perspectiveId,
|
||||
NarrativeDevelopmentKind kind,
|
||||
long otherId)
|
||||
{
|
||||
NarrativeDevelopment development = NarrativeStoryStore.LatestFor(perspectiveId, kind, 3f);
|
||||
if (development == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (otherId != 0 && development.OtherId != 0 && development.OtherId != otherId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return development;
|
||||
}
|
||||
|
||||
private static Actor FirstParent(Actor child)
|
||||
{
|
||||
foreach (Actor parent in ActorRelation.EnumerateParents(child, 1))
|
||||
{
|
||||
if (parent != null)
|
||||
{
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Single English owner for typed narrative presentation.</summary>
|
||||
public static class NarrativeRenderer
|
||||
{
|
||||
public static string Beat(NarrativePresentationModel model)
|
||||
{
|
||||
if (model == null || model.Confidence < 0.75f)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string other = CleanName(model.Other.Name);
|
||||
switch (model.Kind)
|
||||
{
|
||||
case NarrativePresentationKind.BondFormed:
|
||||
return string.IsNullOrEmpty(other) ? "Finds love" : "Finds love with " + other;
|
||||
case NarrativePresentationKind.BondBroken:
|
||||
return string.IsNullOrEmpty(other) ? "Parts from a lover" : "Parts from " + other;
|
||||
case NarrativePresentationKind.SeekingLover:
|
||||
return "Seeks a lover";
|
||||
case NarrativePresentationKind.FriendFormed:
|
||||
return string.IsNullOrEmpty(other) ? "Makes a true friend" : "Befriends " + other;
|
||||
case NarrativePresentationKind.ParentWelcomesChild:
|
||||
{
|
||||
string ordinal = model.Ordinal == 1 ? " first" : "";
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Welcomes a" + ordinal + " child"
|
||||
: "Welcomes" + ordinal + " child " + other;
|
||||
}
|
||||
case NarrativePresentationKind.ChildBorn:
|
||||
return string.IsNullOrEmpty(other) ? "Is born" : "Is born to " + other;
|
||||
case NarrativePresentationKind.FamilyFormed:
|
||||
return "Founds a family";
|
||||
case NarrativePresentationKind.FamilyEnded:
|
||||
return "Their family line ends";
|
||||
case NarrativePresentationKind.FamilyPackFormed:
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Forms a family pack"
|
||||
: "Forms a family pack with " + other;
|
||||
case NarrativePresentationKind.FamilyPackJoined:
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Joins a family pack"
|
||||
: "Joins a family pack with " + other;
|
||||
case NarrativePresentationKind.FamilyPackLeft:
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Leaves a family pack"
|
||||
: "Leaves a family pack shared with " + other;
|
||||
case NarrativePresentationKind.GiftGiven:
|
||||
return string.IsNullOrEmpty(other) ? "Gives a gift" : "Gives " + other + " a gift";
|
||||
case NarrativePresentationKind.GiftReceived:
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Receives a gift"
|
||||
: "Receives a gift from " + other;
|
||||
case NarrativePresentationKind.SharedIntimacy:
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Shares intimacy with their lover"
|
||||
: "Shares intimacy with " + other;
|
||||
case NarrativePresentationKind.Conversation:
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Finishes a conversation"
|
||||
: "Finishes a conversation with " + other;
|
||||
case NarrativePresentationKind.Gossip:
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Shares gossip"
|
||||
: "Shares gossip with " + other;
|
||||
case NarrativePresentationKind.GrievesChild:
|
||||
return string.IsNullOrEmpty(other) ? "Mourns a child" : "Mourns child " + other;
|
||||
case NarrativePresentationKind.GrievesLover:
|
||||
return string.IsNullOrEmpty(other) ? "Mourns a lost lover" : "Mourns lover " + other;
|
||||
case NarrativePresentationKind.GrievesFriend:
|
||||
return string.IsNullOrEmpty(other) ? "Mourns a lost friend" : "Mourns friend " + other;
|
||||
case NarrativePresentationKind.GrievesKin:
|
||||
return string.IsNullOrEmpty(other) ? "Mourns a family member" : "Mourns kin " + other;
|
||||
case NarrativePresentationKind.AuthoredObservation:
|
||||
case NarrativePresentationKind.EnsembleObservation:
|
||||
case NarrativePresentationKind.StatusObservation:
|
||||
case NarrativePresentationKind.WorldObservation:
|
||||
return SafeAuthoredBeat(model);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safety audit only. Semantic models prevent duplication; this detects regressions in rendered
|
||||
/// output and compatibility fallbacks without rewriting their prose.
|
||||
/// </summary>
|
||||
public static bool HasSemanticRedundancy(string beat, NarrativePresentationModel model = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(beat))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string plain = StripRichText(beat).ToLowerInvariant();
|
||||
string[] clauses = plain.Split(new[] { " · " }, StringSplitOptions.None);
|
||||
if (clauses.Length > 1)
|
||||
{
|
||||
string left = clauses[0];
|
||||
for (int i = 1; i < clauses.Length; i++)
|
||||
{
|
||||
string right = clauses[i];
|
||||
if (RepeatsConcept(left, right, "child")
|
||||
|| RepeatsConcept(left, right, "lover")
|
||||
|| RepeatsConcept(left, right, "friend")
|
||||
|| RepeatsConcept(left, right, "parent")
|
||||
|| RepeatsConcept(left, right, "family pack"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string other = CleanName(model?.Other.Name);
|
||||
return !string.IsNullOrEmpty(other) && CountOccurrences(plain, other.ToLowerInvariant()) > 1;
|
||||
}
|
||||
|
||||
public static bool HarnessProbeRelationshipProse(out string detail)
|
||||
{
|
||||
var parent = new LifeSagaIdentity { Id = 1, Name = "Joon", SpeciesId = "human" };
|
||||
var child = new LifeSagaIdentity { Id = 2, Name = "Omyahel", SpeciesId = "human" };
|
||||
var parentBeat = new NarrativePresentationModel
|
||||
{
|
||||
EventId = "event:child:1:2",
|
||||
DevelopmentId = "child:1:2",
|
||||
Kind = NarrativePresentationKind.ParentWelcomesChild,
|
||||
DevelopmentKind = NarrativeDevelopmentKind.ChildBorn,
|
||||
PerspectiveId = parent.Id,
|
||||
Perspective = parent,
|
||||
OtherId = child.Id,
|
||||
Other = child,
|
||||
RelationRole = NarrativeRelationRole.Child,
|
||||
Ordinal = 1
|
||||
};
|
||||
var childBeat = new NarrativePresentationModel
|
||||
{
|
||||
EventId = parentBeat.EventId,
|
||||
DevelopmentId = parentBeat.DevelopmentId,
|
||||
Kind = NarrativePresentationKind.ChildBorn,
|
||||
DevelopmentKind = NarrativeDevelopmentKind.ChildBorn,
|
||||
PerspectiveId = child.Id,
|
||||
Perspective = child,
|
||||
OtherId = parent.Id,
|
||||
Other = parent,
|
||||
RelationRole = NarrativeRelationRole.Parent
|
||||
};
|
||||
var loverBeat = new NarrativePresentationModel
|
||||
{
|
||||
Kind = NarrativePresentationKind.BondFormed,
|
||||
PerspectiveId = parent.Id,
|
||||
Perspective = parent,
|
||||
OtherId = child.Id,
|
||||
Other = child,
|
||||
RelationRole = NarrativeRelationRole.Lover
|
||||
};
|
||||
|
||||
string parentLine = Beat(parentBeat);
|
||||
string childLine = Beat(childBeat);
|
||||
string loverLine = Beat(loverBeat);
|
||||
bool oldDetected = HasSemanticRedundancy(
|
||||
"Gains a child, Omyahel · their child", parentBeat);
|
||||
bool ok = parentLine == "Welcomes first child Omyahel"
|
||||
&& childLine == "Is born to Joon"
|
||||
&& loverLine == "Finds love with Omyahel"
|
||||
&& !HasSemanticRedundancy(parentLine, parentBeat)
|
||||
&& !HasSemanticRedundancy(childLine, childBeat)
|
||||
&& !HasSemanticRedundancy(loverLine, loverBeat)
|
||||
&& oldDetected;
|
||||
detail = "parent='" + parentLine
|
||||
+ "' child='" + childLine
|
||||
+ "' lover='" + loverLine
|
||||
+ "' oldDetected=" + oldDetected
|
||||
+ " pass=" + ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static bool RepeatsConcept(string left, string right, string concept)
|
||||
{
|
||||
return left.IndexOf(concept, StringComparison.Ordinal) >= 0
|
||||
&& right.IndexOf(concept, StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string value, string needle)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(needle))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
int at = 0;
|
||||
while ((at = value.IndexOf(needle, at, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
at += needle.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string CleanName(string value) => (value ?? "").Trim();
|
||||
|
||||
private static string SafeAuthoredBeat(NarrativePresentationModel model)
|
||||
{
|
||||
string beat = StripRichText(model?.AuthoredClause ?? "").Trim();
|
||||
string subject = CleanName(model?.Perspective.Name);
|
||||
if (!string.IsNullOrEmpty(subject)
|
||||
&& beat.StartsWith(subject, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
beat = beat.Substring(subject.Length).TrimStart(' ', '·', '-', ':');
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(beat))
|
||||
{
|
||||
beat = EventReason.HumanizeId(model?.ActionId ?? "");
|
||||
}
|
||||
|
||||
if (beat.IndexOf('{') >= 0 || beat.IndexOf('}') >= 0)
|
||||
{
|
||||
beat = beat.Replace("{a}", "").Replace("{b}", "")
|
||||
.Replace("{id}", EventReason.HumanizeId(model?.ActionId ?? ""))
|
||||
.Replace("{", "").Replace("}", "").Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(beat))
|
||||
{
|
||||
beat = char.ToUpperInvariant(beat[0]) + (beat.Length > 1 ? beat.Substring(1) : "");
|
||||
}
|
||||
|
||||
return beat;
|
||||
}
|
||||
|
||||
private static string StripRichText(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.IndexOf('<') < 0)
|
||||
{
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(value.Length);
|
||||
bool tag = false;
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
char ch = value[i];
|
||||
if (ch == '<')
|
||||
{
|
||||
tag = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag)
|
||||
{
|
||||
if (ch == '>')
|
||||
{
|
||||
tag = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(ch);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bounded counters for selected-caption prose provenance and quality.</summary>
|
||||
public static class NarrativePresentationCoverage
|
||||
{
|
||||
public static int IntakeStructuredCount { get; private set; }
|
||||
public static int IntakeCompatibilityCount { get; private set; }
|
||||
public static int StructuredCount { get; private set; }
|
||||
public static int ExactDevelopmentCount { get; private set; }
|
||||
public static int CompatibilityCount { get; private set; }
|
||||
public static int RedundancyCount { get; private set; }
|
||||
public static string LastSource { get; private set; } = "";
|
||||
public static string LastEventId { get; private set; } = "";
|
||||
public static string LastDevelopmentId { get; private set; } = "";
|
||||
|
||||
public static string Summary =>
|
||||
"intake=" + IntakeStructuredCount + "/" + IntakeCompatibilityCount
|
||||
+ " structured=" + StructuredCount
|
||||
+ " exact=" + ExactDevelopmentCount
|
||||
+ " compat=" + CompatibilityCount
|
||||
+ " redundant=" + RedundancyCount
|
||||
+ " last=" + LastSource
|
||||
+ " event=" + LastEventId
|
||||
+ " development=" + LastDevelopmentId;
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
IntakeStructuredCount = 0;
|
||||
IntakeCompatibilityCount = 0;
|
||||
StructuredCount = 0;
|
||||
ExactDevelopmentCount = 0;
|
||||
CompatibilityCount = 0;
|
||||
RedundancyCount = 0;
|
||||
LastSource = "";
|
||||
LastEventId = "";
|
||||
LastDevelopmentId = "";
|
||||
}
|
||||
|
||||
public static void NoteIntake(InterestCandidate candidate)
|
||||
{
|
||||
if (candidate?.NarrativePresentation != null)
|
||||
{
|
||||
IntakeStructuredCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
IntakeCompatibilityCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Note(
|
||||
string source,
|
||||
string beat,
|
||||
NarrativePresentationModel presentation,
|
||||
bool exactDevelopment)
|
||||
{
|
||||
bool structured = presentation != null;
|
||||
if (structured)
|
||||
{
|
||||
StructuredCount++;
|
||||
if (exactDevelopment)
|
||||
{
|
||||
ExactDevelopmentCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
CompatibilityCount++;
|
||||
}
|
||||
|
||||
if (NarrativeRenderer.HasSemanticRedundancy(beat, presentation))
|
||||
{
|
||||
RedundancyCount++;
|
||||
}
|
||||
|
||||
LastSource = source ?? "";
|
||||
LastEventId = presentation?.EventId ?? "";
|
||||
LastDevelopmentId = presentation?.DevelopmentId ?? "";
|
||||
}
|
||||
}
|
||||
|
|
@ -18,83 +18,46 @@ public static class NarrativeProse
|
|||
long focusId = EventFeedUtil.SafeId(focus);
|
||||
if (focusId == 0 || tip == null) return false;
|
||||
|
||||
NarrativeDevelopmentKind kind = KindForTip(tip);
|
||||
if (kind == NarrativeDevelopmentKind.Unknown) return false;
|
||||
NarrativeDevelopment d = NarrativeStoryStore.LatestFor(focusId, kind, 8f);
|
||||
if (d == null) return false;
|
||||
|
||||
model = new NarrativeBeatModel
|
||||
NarrativePresentationModel presentation = tip.NarrativePresentation;
|
||||
if (presentation != null && presentation.PerspectiveId == focusId)
|
||||
{
|
||||
PerspectiveCharacterId = focusId,
|
||||
DevelopmentId = d.Id,
|
||||
ActionKind = d.Kind,
|
||||
OtherId = d.OtherId,
|
||||
Other = d.Other,
|
||||
Change = d.NewState,
|
||||
Outcome = d.Outcome,
|
||||
Confidence = d.Confidence
|
||||
};
|
||||
NarrativeThread thread = LatestThreadFor(focusId, d.Id);
|
||||
if (thread != null)
|
||||
{
|
||||
model.ThreadId = thread.Id;
|
||||
model.ThreadPhase = thread.Phase;
|
||||
model.ContextEvidence = thread.PressureEvidence;
|
||||
}
|
||||
|
||||
beat = BeatLine(model);
|
||||
context = ContextLine(model, thread);
|
||||
return !string.IsNullOrEmpty(beat);
|
||||
}
|
||||
|
||||
public static string BeatLine(NarrativeBeatModel model)
|
||||
{
|
||||
if (model == null || model.Confidence < 0.75f) return "";
|
||||
string other = model.Other.Name ?? "";
|
||||
switch (model.ActionKind)
|
||||
{
|
||||
case NarrativeDevelopmentKind.BondFormed:
|
||||
return string.IsNullOrEmpty(other) ? "Finds love" : "Finds love with " + other;
|
||||
case NarrativeDevelopmentKind.BondBroken:
|
||||
return string.IsNullOrEmpty(other) ? "Parts from a lover" : "Parts from " + other;
|
||||
case NarrativeDevelopmentKind.ChildBorn:
|
||||
NarrativeDevelopment exact = !string.IsNullOrEmpty(presentation.DevelopmentId)
|
||||
? NarrativeStoryStore.Development(presentation.DevelopmentId)
|
||||
: null;
|
||||
model = new NarrativeBeatModel
|
||||
{
|
||||
int count = NarrativeStoryStore.CountConsequences(
|
||||
model.PerspectiveCharacterId, CharacterConsequenceKind.BecameParent);
|
||||
string first = count == 1 ? " first" : "";
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Welcomes a" + first + " child"
|
||||
: "Welcomes" + first + " child " + other;
|
||||
PerspectiveCharacterId = focusId,
|
||||
DevelopmentId = presentation.DevelopmentId ?? "",
|
||||
ActionKind = presentation.DevelopmentKind,
|
||||
OtherId = presentation.OtherId,
|
||||
Other = presentation.Other,
|
||||
Change = presentation.Change ?? "",
|
||||
Outcome = presentation.Outcome ?? "",
|
||||
Confidence = presentation.Confidence
|
||||
};
|
||||
NarrativeThread exactThread = LatestThreadFor(focusId, presentation.DevelopmentId);
|
||||
if (exactThread != null)
|
||||
{
|
||||
model.ThreadId = exactThread.Id;
|
||||
model.ThreadPhase = exactThread.Phase;
|
||||
model.ContextEvidence = exactThread.PressureEvidence;
|
||||
}
|
||||
|
||||
beat = NarrativeRenderer.Beat(presentation);
|
||||
context = ContextLine(model, exactThread);
|
||||
if (!string.IsNullOrEmpty(beat))
|
||||
{
|
||||
NarrativePresentationCoverage.Note(
|
||||
presentation.EvidenceSource,
|
||||
beat,
|
||||
presentation,
|
||||
exact != null);
|
||||
return true;
|
||||
}
|
||||
case NarrativeDevelopmentKind.FriendFormed:
|
||||
return string.IsNullOrEmpty(other) ? "Makes a true friend" : "Befriends " + other;
|
||||
case NarrativeDevelopmentKind.Death:
|
||||
return string.IsNullOrEmpty(other) ? "Dies" : "Is slain by " + other;
|
||||
case NarrativeDevelopmentKind.Kill:
|
||||
return string.IsNullOrEmpty(other) ? "Takes a life" : "Slays " + other;
|
||||
case NarrativeDevelopmentKind.RoleGained:
|
||||
return string.IsNullOrEmpty(model.Change) ? "Rises in standing" : "Becomes " + Humanize(model.Change);
|
||||
case NarrativeDevelopmentKind.RoleLost:
|
||||
return string.IsNullOrEmpty(model.Change) ? "Loses their standing" : "Is no longer " + Humanize(model.Change);
|
||||
case NarrativeDevelopmentKind.HomeFounded:
|
||||
return "Founds a new home";
|
||||
case NarrativeDevelopmentKind.HomeLost:
|
||||
return "Loses their home";
|
||||
case NarrativeDevelopmentKind.WarJoined:
|
||||
return "Enters the war";
|
||||
case NarrativeDevelopmentKind.WarEnded:
|
||||
return "Emerges from the war";
|
||||
case NarrativeDevelopmentKind.PlotJoined:
|
||||
return "Enters a plot";
|
||||
case NarrativeDevelopmentKind.PlotEnded:
|
||||
return "Sees the plot resolved";
|
||||
case NarrativeDevelopmentKind.Transformation:
|
||||
return string.IsNullOrEmpty(model.Change) ? "Is transformed" : "Becomes " + Humanize(model.Change);
|
||||
case NarrativeDevelopmentKind.Recovery:
|
||||
return "Recovers";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
NarrativePresentationCoverage.Note("missing-presentation", tip.Label, null, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string ContextLine(NarrativeBeatModel model, NarrativeThread thread)
|
||||
|
|
@ -157,21 +120,6 @@ public static class NarrativeProse
|
|||
return "";
|
||||
}
|
||||
|
||||
private static NarrativeDevelopmentKind KindForTip(InterestCandidate tip)
|
||||
{
|
||||
string id = (tip?.AssetId ?? "").ToLowerInvariant();
|
||||
switch (id)
|
||||
{
|
||||
case "set_lover": return NarrativeDevelopmentKind.BondFormed;
|
||||
case "clear_lover": return NarrativeDevelopmentKind.BondBroken;
|
||||
case "add_child": return NarrativeDevelopmentKind.ChildBorn;
|
||||
case "baby_created": return NarrativeDevelopmentKind.ChildBorn;
|
||||
case "king_new":
|
||||
case "become_alpha": return NarrativeDevelopmentKind.RoleGained;
|
||||
default: return NarrativeDevelopmentKind.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
private static NarrativeThread LatestThreadFor(long characterId, string developmentId)
|
||||
{
|
||||
CharacterStoryState state = NarrativeStoryStore.Character(characterId);
|
||||
|
|
@ -191,9 +139,4 @@ public static class NarrativeProse
|
|||
return null;
|
||||
}
|
||||
|
||||
private static string Humanize(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return "";
|
||||
return ActivityAssetCatalog.TitleCaseWords(raw.Replace('_', ' ').Trim());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,9 +31,11 @@ public static class NarrativeStoryStore
|
|||
Threads.Clear();
|
||||
Consequences.Clear();
|
||||
ThreadScratch.Clear();
|
||||
NarrativeEventStore.Clear();
|
||||
Revision++;
|
||||
StoryScheduler.Clear();
|
||||
NarrativeTelemetry.Clear();
|
||||
NarrativePresentationCoverage.Clear();
|
||||
}
|
||||
|
||||
public static CharacterStoryState Character(long id)
|
||||
|
|
@ -63,6 +65,61 @@ public static class NarrativeStoryStore
|
|||
return consequence;
|
||||
}
|
||||
|
||||
internal static IEnumerable<NarrativeDevelopment> AllDevelopments() => Developments.Values;
|
||||
internal static IEnumerable<NarrativeThread> AllThreads() => Threads.Values;
|
||||
internal static IEnumerable<CharacterConsequence> AllConsequences() => Consequences.Values;
|
||||
internal static IEnumerable<CharacterStoryState> AllCharacters() => Characters.Values;
|
||||
|
||||
internal static void ImportDevelopment(NarrativeDevelopment development)
|
||||
{
|
||||
if (development != null && development.SubjectId != 0
|
||||
&& !string.IsNullOrEmpty(development.Id))
|
||||
{
|
||||
Developments[development.Id] = development;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ImportThread(NarrativeThread thread)
|
||||
{
|
||||
if (thread != null && !string.IsNullOrEmpty(thread.Id))
|
||||
{
|
||||
Threads[thread.Id] = thread;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ImportConsequence(CharacterConsequence consequence)
|
||||
{
|
||||
if (consequence != null && !string.IsNullOrEmpty(consequence.Id))
|
||||
{
|
||||
Consequences[consequence.Id] = consequence;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ImportCharacter(CharacterStoryState character)
|
||||
{
|
||||
if (character != null && character.CharacterId != 0)
|
||||
{
|
||||
Characters[character.CharacterId] = character;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void FinishImport()
|
||||
{
|
||||
foreach (CharacterStoryState state in Characters.Values)
|
||||
{
|
||||
if (state == null) continue;
|
||||
state.OpenThreadIds.RemoveAll(id => !Threads.ContainsKey(id));
|
||||
state.ResolvedThreadIds.RemoveAll(id => !Threads.ContainsKey(id));
|
||||
state.RecentDevelopmentIds.RemoveAll(id => !Developments.ContainsKey(id));
|
||||
state.ConsequenceIds.RemoveAll(id => !Consequences.ContainsKey(id));
|
||||
}
|
||||
|
||||
Revision++;
|
||||
StoryScheduler.Clear();
|
||||
NarrativeTelemetry.Clear();
|
||||
NarrativePresentationCoverage.Clear();
|
||||
}
|
||||
|
||||
public static bool Record(NarrativeDevelopment development)
|
||||
{
|
||||
if (development == null || development.SubjectId == 0 || string.IsNullOrEmpty(development.Id))
|
||||
|
|
@ -132,7 +189,10 @@ public static class NarrativeStoryStore
|
|||
thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function));
|
||||
thread.Urgency = Mathf.Max(thread.Urgency, UrgencyFor(development));
|
||||
|
||||
CharacterStoryState state = GetOrCreateCharacter(protagonistId, development.Subject);
|
||||
LifeSagaIdentity protagonistIdentity = protagonistId == development.SubjectId
|
||||
? development.Subject
|
||||
: (protagonistId == development.OtherId ? development.Other : default);
|
||||
CharacterStoryState state = GetOrCreateCharacter(protagonistId, protagonistIdentity);
|
||||
MoveThreadReference(state, thread);
|
||||
RecalculatePotential(state);
|
||||
Revision++;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ public sealed class NarrativeTelemetryEntry
|
|||
public string ActualKey = "";
|
||||
public string Reason = "";
|
||||
public NarrativeInterruptClass InterruptClass;
|
||||
public EpisodeShotRole ShotRole;
|
||||
public float CoverageDebt;
|
||||
public bool ActualRelated;
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +57,9 @@ public static class NarrativeTelemetry
|
|||
ProtagonistId = episode.ProtagonistId,
|
||||
ProposedKey = proposal.Candidate.Key ?? "",
|
||||
Reason = proposal.Reason ?? "",
|
||||
InterruptClass = proposal.InterruptClass
|
||||
InterruptClass = proposal.InterruptClass,
|
||||
ShotRole = proposal.Role,
|
||||
CoverageDebt = NarrativeExposureLedger.ThreadDebt(episode.ThreadId)
|
||||
});
|
||||
Trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public static class StoryScheduler
|
|||
Pending.Clear();
|
||||
_nextTickAt = 0f;
|
||||
_lastProposal = "";
|
||||
NarrativeExposureLedger.Clear();
|
||||
}
|
||||
|
||||
public static void Tick(float now)
|
||||
|
|
@ -41,6 +42,11 @@ public static class StoryScheduler
|
|||
for (int i = 0; i < OpenThreads.Count; i++)
|
||||
{
|
||||
NarrativeThread thread = OpenThreads[i];
|
||||
if (thread != null
|
||||
&& (ActiveEpisode == null || ActiveEpisode.ThreadId != thread.Id))
|
||||
{
|
||||
thread.CoverageDebt = Mathf.Min(20f, thread.CoverageDebt + 0.035f);
|
||||
}
|
||||
float score = Score(thread, now);
|
||||
if (score > bestScore)
|
||||
{
|
||||
|
|
@ -87,6 +93,8 @@ public static class StoryScheduler
|
|||
ActiveEpisode.LastProgressAt = now;
|
||||
ActiveEpisode.CombatShotCount = 0;
|
||||
ActiveEpisode.GoalMet = false;
|
||||
ActiveEpisode.CompletionReason = "";
|
||||
AddPendingForPurpose(ActiveEpisode, PurposeFor(selectedThread));
|
||||
}
|
||||
|
||||
InterestRegistry.CopyPending(Pending);
|
||||
|
|
@ -115,16 +123,42 @@ public static class StoryScheduler
|
|||
NarrativeInterruptClass kind = InterruptPolicy.Classify(actual, ActiveEpisode, thread);
|
||||
if (kind == NarrativeInterruptClass.RelatedEscalation)
|
||||
{
|
||||
NarrativeFunction function = NarrativeFunctionPolicy.Classify(actual);
|
||||
EpisodeShotRole role = EpisodeShotSelector.RoleFor(
|
||||
actual, function, kind, ActiveEpisode);
|
||||
string semanticKey = EpisodeShotSelector.SemanticKey(actual, role);
|
||||
if (IsCombatShot(actual))
|
||||
{
|
||||
ActiveEpisode.CombatShotCount++;
|
||||
}
|
||||
if (actual.SubjectId != 0 && actual.SubjectId == ActiveEpisode.LastSubjectId)
|
||||
ActiveEpisode.RepeatedSubjectCount++;
|
||||
else
|
||||
ActiveEpisode.RepeatedSubjectCount = 1;
|
||||
ActiveEpisode.LastSubjectId = actual.SubjectId;
|
||||
ActiveEpisode.Cover(role);
|
||||
if (!string.IsNullOrEmpty(semanticKey))
|
||||
{
|
||||
ActiveEpisode.RecentSemanticKeys.Remove(semanticKey);
|
||||
ActiveEpisode.RecentSemanticKeys.Insert(0, semanticKey);
|
||||
while (ActiveEpisode.RecentSemanticKeys.Count > 10)
|
||||
ActiveEpisode.RecentSemanticKeys.RemoveAt(
|
||||
ActiveEpisode.RecentSemanticKeys.Count - 1);
|
||||
}
|
||||
ActiveEpisode.LastProgressAt = UnityEngine.Time.unscaledTime;
|
||||
ActiveEpisode.LastShotKey = actual.Key ?? "";
|
||||
ActiveEpisode.InterruptedAt = -1f;
|
||||
ActiveEpisode.GoalMet = thread != null
|
||||
&& (thread.Phase == NarrativePhase.Outcome
|
||||
|| thread.Phase == NarrativePhase.Consequence);
|
||||
if (thread != null)
|
||||
thread.CoverageDebt = Mathf.Max(0f, thread.CoverageDebt - 3f);
|
||||
NarrativeExposureLedger.NoteCut(
|
||||
ActiveEpisode.ProtagonistId,
|
||||
ActiveEpisode.ThreadId,
|
||||
SemanticFamily(actual),
|
||||
UnityEngine.Time.unscaledTime);
|
||||
ActiveEpisode.GoalMet = ActiveEpisode.PendingRoles.Count == 0
|
||||
&& ActiveEpisode.CoveredRoles.Count > 0;
|
||||
if (ActiveEpisode.GoalMet)
|
||||
ActiveEpisode.CompletionReason = "grammar_complete";
|
||||
}
|
||||
else if (kind == NarrativeInterruptClass.WorldCritical || kind == NarrativeInterruptClass.StoryPayoff)
|
||||
{
|
||||
|
|
@ -258,6 +292,91 @@ public static class StoryScheduler
|
|||
return pass;
|
||||
}
|
||||
|
||||
public static bool HarnessProbeGrammar(out string detail)
|
||||
{
|
||||
NarrativeExposureLedger.Clear();
|
||||
var episode = new EpisodePlan
|
||||
{
|
||||
ThreadId = "grammar:thread",
|
||||
ProtagonistId = 501,
|
||||
Purpose = EpisodePurpose.Develop
|
||||
};
|
||||
episode.CoveredRoles.Add(EpisodeShotRole.Development);
|
||||
episode.PendingRoles.Add(EpisodeShotRole.Pressure);
|
||||
episode.RecentSemanticKeys.Add("Development:routine:501");
|
||||
var thread = new NarrativeThread
|
||||
{
|
||||
Id = episode.ThreadId,
|
||||
ProtagonistId = 501,
|
||||
Status = NarrativeThreadStatus.Active
|
||||
};
|
||||
var repeated = new InterestCandidate
|
||||
{
|
||||
Key = "repeated",
|
||||
AssetId = "routine",
|
||||
SubjectId = 501,
|
||||
EventStrength = 92f,
|
||||
HasNarrativeFunction = true,
|
||||
NarrativeFunction = NarrativeFunction.Development
|
||||
};
|
||||
var pressure = new InterestCandidate
|
||||
{
|
||||
Key = "pressure",
|
||||
AssetId = "war_started",
|
||||
SubjectId = 501,
|
||||
EventStrength = 60f,
|
||||
HasNarrativeFunction = true,
|
||||
NarrativeFunction = NarrativeFunction.Escalation
|
||||
};
|
||||
EpisodeShotProposal progressive = EpisodeShotSelector.Pick(
|
||||
new List<InterestCandidate> { repeated, pressure },
|
||||
episode,
|
||||
thread,
|
||||
requirePresentable: false);
|
||||
|
||||
var texture = new InterestCandidate
|
||||
{
|
||||
Key = "texture",
|
||||
AssetId = "sleeping",
|
||||
SubjectId = 501,
|
||||
EventStrength = 80f,
|
||||
HasNarrativeFunction = true,
|
||||
NarrativeFunction = NarrativeFunction.CharacterTexture
|
||||
};
|
||||
EpisodeShotProposal noForced = EpisodeShotSelector.Pick(
|
||||
new List<InterestCandidate> { texture },
|
||||
episode,
|
||||
thread,
|
||||
requirePresentable: false);
|
||||
var critical = new InterestCandidate
|
||||
{
|
||||
Key = "critical",
|
||||
AssetId = "earthquake",
|
||||
SubjectId = 900,
|
||||
Category = "Disaster",
|
||||
EventStrength = 100f,
|
||||
HasNarrativeFunction = true,
|
||||
NarrativeFunction = NarrativeFunction.WorldContext
|
||||
};
|
||||
EpisodeShotProposal interrupt = EpisodeShotSelector.Pick(
|
||||
new List<InterestCandidate> { critical },
|
||||
episode,
|
||||
thread,
|
||||
requirePresentable: false);
|
||||
bool ok = progressive?.Candidate?.Key == "pressure"
|
||||
&& progressive.Role == EpisodeShotRole.Pressure
|
||||
&& noForced == null
|
||||
&& interrupt?.Candidate?.Key == "critical"
|
||||
&& interrupt.Role == EpisodeShotRole.WorldContext;
|
||||
detail = "progressive=" + (progressive?.Candidate?.Key ?? "none")
|
||||
+ "/" + (progressive?.Role.ToString() ?? "none")
|
||||
+ " noForced=" + (noForced == null)
|
||||
+ " interrupt=" + (interrupt?.Candidate?.Key ?? "none")
|
||||
+ "/" + (interrupt?.Role.ToString() ?? "none")
|
||||
+ " pass=" + ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
public static float StoryPotential(long characterId)
|
||||
{
|
||||
CharacterStoryState state = NarrativeStoryStore.Character(characterId);
|
||||
|
|
@ -282,6 +401,8 @@ public static class StoryScheduler
|
|||
float mc = LifeSagaRoster.IsMc(thread.ProtagonistId) ? 4f : 0f;
|
||||
float repeat = ActiveEpisode != null && ActiveEpisode.ThreadId == thread.Id ? 2f : 0f;
|
||||
return potential + thread.Momentum + thread.Urgency + thread.CoverageDebt
|
||||
+ NarrativeExposureLedger.CharacterDebt(thread.ProtagonistId)
|
||||
+ NarrativeExposureLedger.ThreadDebt(thread.Id)
|
||||
+ freshness + payoff + prefer + mc + repeat;
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +446,7 @@ public static class StoryScheduler
|
|||
StartedAt = now,
|
||||
LastProgressAt = now
|
||||
};
|
||||
AddPendingForPurpose(episode, episode.Purpose);
|
||||
for (int i = 0; i < thread.Cast.Count; i++)
|
||||
{
|
||||
NarrativeCastRole cast = thread.Cast[i];
|
||||
|
|
@ -334,6 +456,39 @@ public static class StoryScheduler
|
|||
return episode;
|
||||
}
|
||||
|
||||
private static void AddPendingForPurpose(EpisodePlan episode, EpisodePurpose purpose)
|
||||
{
|
||||
if (episode == null) return;
|
||||
void Add(EpisodeShotRole role)
|
||||
{
|
||||
if (!episode.CoveredRoles.Contains(role) && !episode.PendingRoles.Contains(role))
|
||||
episode.PendingRoles.Add(role);
|
||||
}
|
||||
|
||||
switch (purpose)
|
||||
{
|
||||
case EpisodePurpose.Establish:
|
||||
Add(EpisodeShotRole.Establish);
|
||||
Add(EpisodeShotRole.Development);
|
||||
break;
|
||||
case EpisodePurpose.Payoff:
|
||||
Add(EpisodeShotRole.TurningPoint);
|
||||
Add(EpisodeShotRole.Payoff);
|
||||
break;
|
||||
case EpisodePurpose.Consequence:
|
||||
Add(EpisodeShotRole.Consequence);
|
||||
break;
|
||||
case EpisodePurpose.Reintroduce:
|
||||
Add(EpisodeShotRole.Reintroduction);
|
||||
Add(EpisodeShotRole.Development);
|
||||
break;
|
||||
default:
|
||||
Add(EpisodeShotRole.Development);
|
||||
Add(EpisodeShotRole.Pressure);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCombatShot(InterestCandidate candidate)
|
||||
{
|
||||
return candidate != null
|
||||
|
|
@ -342,6 +497,20 @@ public static class StoryScheduler
|
|||
|| string.Equals(candidate.AssetId, "live_battle", System.StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
internal static string SemanticFamily(InterestCandidate candidate)
|
||||
{
|
||||
if (candidate == null) return "";
|
||||
NarrativeDevelopmentKind kind =
|
||||
candidate.NarrativePresentation?.DevelopmentKind
|
||||
?? NarrativeDevelopmentKind.Unknown;
|
||||
if (kind != NarrativeDevelopmentKind.Unknown) return kind.ToString();
|
||||
if (!string.IsNullOrEmpty(candidate.HappinessEffectId))
|
||||
return candidate.HappinessEffectId;
|
||||
if (!string.IsNullOrEmpty(candidate.Category))
|
||||
return candidate.Category;
|
||||
return candidate.AssetId ?? "";
|
||||
}
|
||||
|
||||
private static EpisodePurpose PurposeFor(NarrativeThread thread)
|
||||
{
|
||||
if (thread == null) return EpisodePurpose.Develop;
|
||||
|
|
|
|||
|
|
@ -244,42 +244,9 @@ public static class CaptionComposer
|
|||
relatedId = otherId;
|
||||
}
|
||||
|
||||
// Ensemble tips: no cast-name injection; rematch clause OK when pair/related already named.
|
||||
bool ensemble = SagaProse.IsEnsembleTip(beat);
|
||||
Actor other = otherId != 0 ? EventFeedUtil.FindAliveById(otherId) : null;
|
||||
if (other == null && otherId != 0)
|
||||
{
|
||||
other = EventFeedUtil.FindUnitById(otherId);
|
||||
}
|
||||
|
||||
if (focusId != 0 && otherId != 0 && other != null)
|
||||
{
|
||||
RelationEvidence ev = SagaProse.ResolveRelation(focusId, otherId, focus, other);
|
||||
string verb = SagaProse.ClassifyTipVerb(beat);
|
||||
string clause = SagaProse.BeatClause(focus, other, ev, beat, verb);
|
||||
if (!string.IsNullOrEmpty(clause))
|
||||
{
|
||||
// Skip weak clauses on pure ensemble without the other name in tip.
|
||||
if (ensemble)
|
||||
{
|
||||
string otherName = EventFeedUtil.SafeName(other);
|
||||
if (!string.IsNullOrEmpty(otherName)
|
||||
&& beat.IndexOf(otherName, StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& tip != null
|
||||
&& tip.PairPartnerId == 0
|
||||
&& tip.RelatedId == 0)
|
||||
{
|
||||
clause = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(clause)
|
||||
&& result.IndexOf(clause, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
result = result.TrimEnd() + " · " + clause;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The selected reason is a complete semantic beat. Relationship evidence belongs in the
|
||||
// typed renderer; appending a generic "their child/lover/friend" clause here caused the
|
||||
// same fact to be stated twice and could change the meaning of authored ensemble labels.
|
||||
|
||||
if (isReentry && LifeSagaRoster.IsMc(focusId))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ public static class LifeSagaMemory
|
|||
private static void MarkMutated()
|
||||
{
|
||||
SagaProse.BumpMemoryRevision();
|
||||
NarrativePersistence.MarkDirty();
|
||||
}
|
||||
|
||||
public static int RivalEncounterThreshold
|
||||
|
|
@ -112,6 +113,40 @@ public static class LifeSagaMemory
|
|||
return life != null ? life.Facts : Array.Empty<LifeSagaFact>();
|
||||
}
|
||||
|
||||
internal static IEnumerable<LifeSagaFact> AllFacts()
|
||||
{
|
||||
foreach (LifeSagaLifeMemory life in Lives.Values)
|
||||
{
|
||||
if (life == null) continue;
|
||||
for (int i = 0; i < life.Facts.Count; i++)
|
||||
{
|
||||
if (life.Facts[i] != null) yield return life.Facts[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ImportFact(LifeSagaFact fact)
|
||||
{
|
||||
if (fact == null || fact.SubjectId == 0 || string.IsNullOrEmpty(fact.Key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaLifeMemory life = GetOrCreate(fact.SubjectId);
|
||||
if (life == null || FindByKey(life, fact.Key) != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (fact.Subject.Id != 0) life.Identity = fact.Subject;
|
||||
life.Facts.Add(fact);
|
||||
life.TouchedAt = Mathf.Max(life.TouchedAt, fact.At);
|
||||
while (life.Facts.Count > MaxFactsPerLife)
|
||||
{
|
||||
life.Facts.RemoveAt(life.Facts.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
public static LifeSagaFact Record(
|
||||
LifeSagaFactKind kind,
|
||||
Actor subject,
|
||||
|
|
@ -282,14 +317,25 @@ public static class LifeSagaMemory
|
|||
return fact;
|
||||
}
|
||||
|
||||
public static void RecordBondFormed(Actor a, Actor b, string provenance = "set_lover")
|
||||
public static NarrativeEventReceipt RecordBondFormed(
|
||||
Actor a,
|
||||
Actor b,
|
||||
string provenance = "set_lover")
|
||||
{
|
||||
return NarrativeEventRecorder.BondFormed(a, b, provenance);
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordBondFormedFact(
|
||||
Actor a,
|
||||
Actor b,
|
||||
string provenance = "set_lover")
|
||||
{
|
||||
if (a == null || b == null)
|
||||
{
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
Record(
|
||||
return Record(
|
||||
LifeSagaFactKind.BondFormed,
|
||||
a,
|
||||
b,
|
||||
|
|
@ -297,14 +343,24 @@ public static class LifeSagaMemory
|
|||
+ Time.unscaledTime.ToString("0.###"),
|
||||
strength: 70f,
|
||||
provenance: provenance);
|
||||
NarrativeDevelopmentRouter.BondFormed(a, b, provenance);
|
||||
}
|
||||
|
||||
public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover")
|
||||
public static NarrativeEventReceipt RecordBondBroken(
|
||||
Actor survivor,
|
||||
Actor former,
|
||||
string provenance = "clear_lover")
|
||||
{
|
||||
return NarrativeEventRecorder.BondBroken(survivor, former, provenance);
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordBondBrokenFact(
|
||||
Actor survivor,
|
||||
Actor former,
|
||||
string provenance = "clear_lover")
|
||||
{
|
||||
if (survivor == null)
|
||||
{
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
long survivorId = EventFeedUtil.SafeId(survivor);
|
||||
|
|
@ -329,10 +385,10 @@ public static class LifeSagaMemory
|
|||
|
||||
if (formerSnap.Id == 0)
|
||||
{
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
RecordIds(
|
||||
return RecordIds(
|
||||
LifeSagaFactKind.BondBroken,
|
||||
survivorId,
|
||||
Snapshot(survivor),
|
||||
|
|
@ -343,55 +399,105 @@ public static class LifeSagaMemory
|
|||
strength: 75f,
|
||||
provenance: provenance,
|
||||
resolved: true);
|
||||
NarrativeDevelopmentRouter.BondBroken(survivor, former, provenance);
|
||||
}
|
||||
|
||||
public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child")
|
||||
public static NarrativeEventReceipt RecordParentChild(
|
||||
Actor parent,
|
||||
Actor child,
|
||||
string provenance = "add_child")
|
||||
{
|
||||
return NarrativeEventRecorder.ChildBorn(parent, child, provenance);
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordParentChildFact(
|
||||
Actor parent,
|
||||
Actor child,
|
||||
string provenance = "add_child")
|
||||
{
|
||||
if (parent == null || child == null)
|
||||
{
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
long parentId = EventFeedUtil.SafeId(parent);
|
||||
long childId = EventFeedUtil.SafeId(child);
|
||||
Record(
|
||||
return Record(
|
||||
LifeSagaFactKind.ParentChild,
|
||||
parent,
|
||||
child,
|
||||
correlationKey: "parent:" + parentId + ":" + childId,
|
||||
strength: 65f,
|
||||
provenance: provenance);
|
||||
NarrativeDevelopmentRouter.ChildBorn(parent, child, provenance);
|
||||
}
|
||||
|
||||
public static void RecordFriendFormed(Actor a, Actor b, string provenance = "set_best_friend")
|
||||
public static NarrativeEventReceipt RecordFriendFormed(
|
||||
Actor a,
|
||||
Actor b,
|
||||
string provenance = "set_best_friend")
|
||||
{
|
||||
return NarrativeEventRecorder.FriendFormed(a, b, provenance);
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordFriendFormedFact(
|
||||
Actor a,
|
||||
Actor b,
|
||||
string provenance = "set_best_friend")
|
||||
{
|
||||
if (a == null || b == null)
|
||||
{
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
Record(
|
||||
return Record(
|
||||
LifeSagaFactKind.FriendFormed,
|
||||
a,
|
||||
b,
|
||||
correlationKey: "friend:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)),
|
||||
strength: 55f,
|
||||
provenance: provenance);
|
||||
NarrativeDevelopmentRouter.FriendFormed(a, b, provenance);
|
||||
}
|
||||
|
||||
public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction")
|
||||
public static NarrativeEventReceipt RecordKill(
|
||||
Actor killer,
|
||||
Actor victim,
|
||||
string provenance = "newKillAction")
|
||||
{
|
||||
if (killer == null || victim == null)
|
||||
{
|
||||
return;
|
||||
return new NarrativeEventReceipt { RejectionReason = "missing_actor" };
|
||||
}
|
||||
|
||||
long killerId = EventFeedUtil.SafeId(killer);
|
||||
long victimId = EventFeedUtil.SafeId(victim);
|
||||
Record(
|
||||
NarrativeEventReceipt receipt = NarrativeEventRecorder.Kill(killer, victim, provenance);
|
||||
|
||||
if (receipt.IsNew)
|
||||
{
|
||||
// Kill/death must not increment living rematch counters (single kill ≠ rival).
|
||||
TryMarkKinKillRival(killer, victim);
|
||||
if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId))
|
||||
{
|
||||
// MC slew MC: crossover stake only - not automatic RivalEarned.
|
||||
NoteMcCrossoverHeat(killerId, victimId);
|
||||
}
|
||||
}
|
||||
|
||||
return receipt;
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordKillFact(
|
||||
Actor killer,
|
||||
Actor victim,
|
||||
string provenance = "newKillAction")
|
||||
{
|
||||
if (killer == null || victim == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long killerId = EventFeedUtil.SafeId(killer);
|
||||
long victimId = EventFeedUtil.SafeId(victim);
|
||||
return Record(
|
||||
LifeSagaFactKind.Kill,
|
||||
killer,
|
||||
victim,
|
||||
|
|
@ -399,26 +505,47 @@ public static class LifeSagaMemory
|
|||
+ Time.unscaledTime.ToString("0.###"),
|
||||
strength: 80f,
|
||||
provenance: provenance);
|
||||
NarrativeDevelopmentRouter.Kill(killer, victim, provenance);
|
||||
|
||||
// Kill/death must not increment living rematch counters (single kill ≠ rival).
|
||||
TryMarkKinKillRival(killer, victim);
|
||||
if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId))
|
||||
{
|
||||
// MC slew MC: crossover stake only - not automatic RivalEarned.
|
||||
NoteMcCrossoverHeat(killerId, victimId);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecordDeath(Actor victim, Actor killer, string attackType, string provenance = "die")
|
||||
public static NarrativeEventReceipt RecordDeath(
|
||||
Actor victim,
|
||||
Actor killer,
|
||||
string attackType,
|
||||
string provenance = "die")
|
||||
{
|
||||
if (victim == null)
|
||||
{
|
||||
return;
|
||||
return new NarrativeEventReceipt { RejectionReason = "missing_actor" };
|
||||
}
|
||||
|
||||
long victimId = EventFeedUtil.SafeId(victim);
|
||||
Record(
|
||||
NarrativeEventReceipt receipt =
|
||||
NarrativeEventRecorder.Death(victim, killer, attackType, provenance);
|
||||
if (receipt.IsNew && killer != null)
|
||||
{
|
||||
long killerId = EventFeedUtil.SafeId(killer);
|
||||
if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId))
|
||||
{
|
||||
NoteMcCrossoverHeat(killerId, victimId);
|
||||
}
|
||||
}
|
||||
|
||||
return receipt;
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordDeathFact(
|
||||
Actor victim,
|
||||
Actor killer,
|
||||
string attackType,
|
||||
string provenance = "die")
|
||||
{
|
||||
if (victim == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long victimId = EventFeedUtil.SafeId(victim);
|
||||
return Record(
|
||||
LifeSagaFactKind.Death,
|
||||
victim,
|
||||
killer,
|
||||
|
|
@ -427,16 +554,6 @@ public static class LifeSagaMemory
|
|||
provenance: provenance,
|
||||
resolved: true,
|
||||
note: attackType ?? "");
|
||||
NarrativeDevelopmentRouter.Death(victim, killer, attackType, provenance);
|
||||
|
||||
if (killer != null)
|
||||
{
|
||||
long killerId = EventFeedUtil.SafeId(killer);
|
||||
if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId))
|
||||
{
|
||||
NoteMcCrossoverHeat(killerId, victimId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -625,7 +742,7 @@ public static class LifeSagaMemory
|
|||
EarnRival(a, b, "opposed_in_plot:" + plotKey, provenance);
|
||||
}
|
||||
|
||||
public static void RecordWar(
|
||||
public static NarrativeEventReceipt RecordWar(
|
||||
Actor subject,
|
||||
string warKey,
|
||||
string kingdomKey,
|
||||
|
|
@ -636,10 +753,28 @@ public static class LifeSagaMemory
|
|||
{
|
||||
if (subject == null || string.IsNullOrEmpty(warKey))
|
||||
{
|
||||
return;
|
||||
return new NarrativeEventReceipt { RejectionReason = "missing_identity" };
|
||||
}
|
||||
|
||||
Record(
|
||||
return NarrativeEventRecorder.War(
|
||||
subject, other, warKey, kingdomKey, ended, provenance, note);
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordWarFact(
|
||||
Actor subject,
|
||||
string warKey,
|
||||
string kingdomKey,
|
||||
bool ended,
|
||||
string provenance = "war",
|
||||
Actor other = null,
|
||||
string note = "")
|
||||
{
|
||||
if (subject == null || string.IsNullOrEmpty(warKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Record(
|
||||
ended ? LifeSagaFactKind.WarEnd : LifeSagaFactKind.WarJoin,
|
||||
subject,
|
||||
other,
|
||||
|
|
@ -650,7 +785,6 @@ public static class LifeSagaMemory
|
|||
warKey: warKey,
|
||||
resolved: ended,
|
||||
note: note ?? "");
|
||||
NarrativeDevelopmentRouter.War(subject, other, warKey, ended, provenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -721,7 +855,7 @@ public static class LifeSagaMemory
|
|||
}
|
||||
}
|
||||
|
||||
public static void RecordPlot(
|
||||
public static NarrativeEventReceipt RecordPlot(
|
||||
Actor subject,
|
||||
string plotKey,
|
||||
string plotAsset,
|
||||
|
|
@ -731,11 +865,28 @@ public static class LifeSagaMemory
|
|||
{
|
||||
if (subject == null || string.IsNullOrEmpty(plotKey))
|
||||
{
|
||||
return;
|
||||
return new NarrativeEventReceipt { RejectionReason = "missing_identity" };
|
||||
}
|
||||
|
||||
return NarrativeEventRecorder.Plot(
|
||||
subject, other, plotKey, plotAsset, finished, provenance);
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordPlotFact(
|
||||
Actor subject,
|
||||
string plotKey,
|
||||
string plotAsset,
|
||||
bool finished,
|
||||
string provenance = "plot",
|
||||
Actor other = null)
|
||||
{
|
||||
if (subject == null || string.IsNullOrEmpty(plotKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string display = ResolvePlotDisplay(plotAsset);
|
||||
Record(
|
||||
return Record(
|
||||
finished ? LifeSagaFactKind.PlotEnd : LifeSagaFactKind.PlotJoin,
|
||||
subject,
|
||||
other,
|
||||
|
|
@ -745,7 +896,6 @@ public static class LifeSagaMemory
|
|||
plotKey: plotKey,
|
||||
note: display,
|
||||
resolved: finished);
|
||||
NarrativeDevelopmentRouter.Plot(subject, other, plotKey, finished, provenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -966,14 +1116,34 @@ public static class LifeSagaMemory
|
|||
return EventReason.HumanizeId(plotAsset);
|
||||
}
|
||||
|
||||
public static void RecordFounding(Actor subject, string metaKind, string metaKey, string provenance)
|
||||
internal static string PlotDisplay(string plotAsset) => ResolvePlotDisplay(plotAsset);
|
||||
|
||||
public static NarrativeEventReceipt RecordFounding(
|
||||
Actor subject,
|
||||
string metaKind,
|
||||
string metaKey,
|
||||
string provenance)
|
||||
{
|
||||
if (subject == null)
|
||||
{
|
||||
return;
|
||||
return new NarrativeEventReceipt { RejectionReason = "missing_actor" };
|
||||
}
|
||||
|
||||
Record(
|
||||
return NarrativeEventRecorder.Founding(subject, metaKind, metaKey, provenance);
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordFoundingFact(
|
||||
Actor subject,
|
||||
string metaKind,
|
||||
string metaKey,
|
||||
string provenance)
|
||||
{
|
||||
if (subject == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Record(
|
||||
LifeSagaFactKind.Founding,
|
||||
subject,
|
||||
null,
|
||||
|
|
@ -985,40 +1155,72 @@ public static class LifeSagaMemory
|
|||
familyKey: metaKind == "family" ? metaKey : "",
|
||||
clanKey: metaKind == "clan" ? metaKey : "",
|
||||
note: metaKind ?? "");
|
||||
NarrativeDevelopmentRouter.Founding(subject, metaKind, metaKey, provenance);
|
||||
}
|
||||
|
||||
public static void RecordRole(Actor subject, string roleId, string provenance = "happiness")
|
||||
public static NarrativeEventReceipt RecordRole(
|
||||
Actor subject,
|
||||
string roleId,
|
||||
string provenance = "happiness")
|
||||
{
|
||||
if (subject == null || string.IsNullOrEmpty(roleId))
|
||||
{
|
||||
return;
|
||||
return new NarrativeEventReceipt { RejectionReason = "missing_identity" };
|
||||
}
|
||||
|
||||
Record(
|
||||
return NarrativeEventRecorder.Role(subject, roleId, true, provenance);
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordRoleFact(
|
||||
Actor subject,
|
||||
string roleId,
|
||||
bool gained,
|
||||
string provenance = "happiness")
|
||||
{
|
||||
if (subject == null || string.IsNullOrEmpty(roleId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Record(
|
||||
LifeSagaFactKind.RoleChange,
|
||||
subject,
|
||||
null,
|
||||
correlationKey: "role:" + EventFeedUtil.SafeId(subject) + ":" + roleId,
|
||||
correlationKey: "role:" + EventFeedUtil.SafeId(subject) + ":" + roleId + ":" + gained,
|
||||
strength: 68f,
|
||||
provenance: provenance,
|
||||
resolved: !gained,
|
||||
note: roleId);
|
||||
NarrativeDevelopmentRouter.Role(subject, roleId, true, provenance);
|
||||
}
|
||||
|
||||
public static void RecordHome(Actor subject, bool gained, string provenance = "happiness")
|
||||
public static NarrativeEventReceipt RecordHome(
|
||||
Actor subject,
|
||||
bool gained,
|
||||
string provenance = "happiness")
|
||||
{
|
||||
if (subject == null)
|
||||
{
|
||||
return;
|
||||
return new NarrativeEventReceipt { RejectionReason = "missing_actor" };
|
||||
}
|
||||
|
||||
Record(
|
||||
return NarrativeEventRecorder.Home(subject, gained, provenance);
|
||||
}
|
||||
|
||||
internal static LifeSagaFact RecordHomeFact(
|
||||
Actor subject,
|
||||
bool gained,
|
||||
string provenance = "happiness")
|
||||
{
|
||||
if (subject == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Record(
|
||||
gained ? LifeSagaFactKind.HomeGained : LifeSagaFactKind.HomeLost,
|
||||
subject,
|
||||
null,
|
||||
correlationKey: "home:" + EventFeedUtil.SafeId(subject) + ":" + (gained ? "gain" : "loss") + ":"
|
||||
+ Time.unscaledTime.ToString("0.###"),
|
||||
correlationKey: "home:" + EventFeedUtil.SafeId(subject) + ":"
|
||||
+ (gained ? "gain" : "loss"),
|
||||
strength: 50f,
|
||||
provenance: provenance);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -629,7 +629,10 @@ public static class LifeSagaPresentation
|
|||
}
|
||||
}
|
||||
|
||||
private static void BuildLegacy(LifeSagaViewModel model, long unitId, LifeSagaFact stake)
|
||||
private static void BuildLegacy(
|
||||
LifeSagaViewModel model,
|
||||
long unitId,
|
||||
LifeSagaFact stake)
|
||||
{
|
||||
var castIds = new HashSet<long>();
|
||||
for (int i = 0; i < model.Cast.Count; i++)
|
||||
|
|
@ -640,303 +643,8 @@ public static class LifeSagaPresentation
|
|||
}
|
||||
}
|
||||
|
||||
int parentedCount = LifeSagaMemory.CountParentedChildren(unitId);
|
||||
// Many children: prefer one lineage summary over named birth crumbs.
|
||||
bool preferFamilySummary = parentedCount >= 3;
|
||||
// Reserve the final Legacy slot for that summary; otherwise a death/bond/war
|
||||
// burst can fill all four slots and make the lineage disappear nondeterministically.
|
||||
int factLimit = parentedCount >= 2 ? 3 : 4;
|
||||
bool wroteParentChild = false;
|
||||
|
||||
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
|
||||
var seenLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
List<NarrativeChapter> narrativeChapters = NarrativeStoryStore.ChaptersFor(unitId, 4);
|
||||
bool narrativeOwnsFamily = false;
|
||||
bool narrativeOwnsConflict = false;
|
||||
for (int i = 0; i < narrativeChapters.Count && model.Legacy.Count < 4; i++)
|
||||
{
|
||||
NarrativeChapter chapter = narrativeChapters[i];
|
||||
if (chapter == null || string.IsNullOrEmpty(chapter.Line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string line = !string.IsNullOrEmpty(chapter.DateLabel)
|
||||
? chapter.DateLabel + " · " + chapter.Line
|
||||
: chapter.Line;
|
||||
if (!seenLines.Add(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
NarrativeThread chapterThread = NarrativeStoryStore.Thread(chapter.ThreadId);
|
||||
if (chapterThread != null)
|
||||
{
|
||||
narrativeOwnsFamily |= chapterThread.Kind == NarrativeThreadKind.Partnership
|
||||
|| chapterThread.Kind == NarrativeThreadKind.Lineage
|
||||
|| chapterThread.Kind == NarrativeThreadKind.SeparationGrief;
|
||||
narrativeOwnsConflict |= chapterThread.Kind == NarrativeThreadKind.Rivalry
|
||||
|| chapterThread.Kind == NarrativeThreadKind.PersonalCombat
|
||||
|| chapterThread.Kind == NarrativeThreadKind.WarInvolvement;
|
||||
}
|
||||
|
||||
model.Legacy.Add(new LifeSagaLegacyBeat
|
||||
{
|
||||
Kind = NarrativeLegacyKind(chapterThread),
|
||||
Line = line,
|
||||
Truth = "observed",
|
||||
At = chapter.At,
|
||||
DateLabel = chapter.DateLabel ?? ""
|
||||
});
|
||||
}
|
||||
|
||||
bool wroteCombatSummary = false;
|
||||
if (TryBuildCombatSummary(unitId, out LifeSagaLegacyBeat combatSummary))
|
||||
{
|
||||
// PersonalCombat chapters are still one chapter per opponent. Replace those
|
||||
// repeated kill lines with one observed run; earned Rivalry chapters use a
|
||||
// different kind and remain intact.
|
||||
for (int i = model.Legacy.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (model.Legacy[i] != null && model.Legacy[i].Kind == LifeSagaFactKind.Kill)
|
||||
{
|
||||
model.Legacy.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.Legacy.Count < factLimit && seenLines.Add(combatSummary.Line))
|
||||
{
|
||||
model.Legacy.Add(combatSummary);
|
||||
wroteCombatSummary = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < facts.Count; i++)
|
||||
{
|
||||
if (model.Legacy.Count >= factLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
LifeSagaFact f = facts[i];
|
||||
if (f == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (narrativeOwnsFamily
|
||||
&& (f.Kind == LifeSagaFactKind.BondFormed
|
||||
|| f.Kind == LifeSagaFactKind.BondBroken
|
||||
|| f.Kind == LifeSagaFactKind.ParentChild
|
||||
|| f.Kind == LifeSagaFactKind.HardArcLove
|
||||
|| f.Kind == LifeSagaFactKind.HardArcGrief))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (narrativeOwnsConflict
|
||||
&& (f.Kind == LifeSagaFactKind.Kill
|
||||
|| f.Kind == LifeSagaFactKind.HardArcCombat
|
||||
|| f.Kind == LifeSagaFactKind.HardArcWar))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wroteCombatSummary && f.Kind == LifeSagaFactKind.Kill)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stake already owns the strongest unresolved beat - do not repeat it in Legacy.
|
||||
if (stake != null
|
||||
&& ((!string.IsNullOrEmpty(stake.Key)
|
||||
&& string.Equals(stake.Key, f.Key, StringComparison.Ordinal))
|
||||
|| (stake.Kind == f.Kind && stake.Other.Id != 0 && stake.Other.Id == f.Other.Id)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Living Cast already owns present relationships - Legacy is turning points.
|
||||
if (CastOwnsLivingRelation(f, castIds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Current role is already stated in Identity (for example
|
||||
// "Adisolf · King of Nuanand"). Do not repeat "Became King" below it.
|
||||
if (f.Kind == LifeSagaFactKind.RoleChange
|
||||
&& SagaProse.RoleChangeEchoesTitle(f, model.Title))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.Kind == LifeSagaFactKind.Founding
|
||||
&& !string.IsNullOrEmpty(model.Title)
|
||||
&& model.Title.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.Kind == LifeSagaFactKind.ParentChild)
|
||||
{
|
||||
if (preferFamilySummary || wroteParentChild)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
string line = LifeSagaMemory.FormatLegacyLine(f);
|
||||
if (string.IsNullOrEmpty(line)
|
||||
|| string.Equals(line, model.StakeLine, StringComparison.OrdinalIgnoreCase)
|
||||
|| !seenLines.Add(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
model.Legacy.Add(new LifeSagaLegacyBeat
|
||||
{
|
||||
Kind = f.Kind,
|
||||
Line = line,
|
||||
Truth = f.Resolved ? "observed" : "observed",
|
||||
At = f.At,
|
||||
DateLabel = f.DateLabel ?? ""
|
||||
});
|
||||
|
||||
if (f.Kind == LifeSagaFactKind.ParentChild)
|
||||
{
|
||||
wroteParentChild = true;
|
||||
}
|
||||
|
||||
if (model.Legacy.Count >= factLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wroteParentChild
|
||||
&& !narrativeOwnsFamily
|
||||
&& parentedCount >= 2
|
||||
&& model.Legacy.Count < 4)
|
||||
{
|
||||
string summary = LifeSagaMemory.FormatFamilySummary(parentedCount);
|
||||
if (!string.IsNullOrEmpty(summary)
|
||||
&& !string.Equals(summary, model.StakeLine, StringComparison.OrdinalIgnoreCase)
|
||||
&& seenLines.Add(summary))
|
||||
{
|
||||
model.Legacy.Add(new LifeSagaLegacyBeat
|
||||
{
|
||||
Kind = LifeSagaFactKind.ParentChild,
|
||||
Line = summary,
|
||||
Truth = "observed",
|
||||
At = 0f,
|
||||
DateLabel = ""
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryBuildCombatSummary(long unitId, out LifeSagaLegacyBeat summary)
|
||||
{
|
||||
summary = null;
|
||||
IReadOnlyList<LifeSagaFact> all = LifeSagaMemory.FactsFor(unitId);
|
||||
if (all == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int kills = 0;
|
||||
LifeSagaFact earliest = null;
|
||||
LifeSagaFact latest = null;
|
||||
for (int i = 0; i < all.Count; i++)
|
||||
{
|
||||
LifeSagaFact fact = all[i];
|
||||
if (fact == null || fact.Kind != LifeSagaFactKind.Kill)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
kills++;
|
||||
if (earliest == null || fact.At < earliest.At) earliest = fact;
|
||||
if (latest == null || fact.At > latest.At) latest = fact;
|
||||
}
|
||||
|
||||
if (kills < 2 || latest == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string date = latest.DateLabel ?? "";
|
||||
if (earliest != null
|
||||
&& !string.IsNullOrEmpty(earliest.DateLabel)
|
||||
&& !string.IsNullOrEmpty(latest.DateLabel)
|
||||
&& !string.Equals(earliest.DateLabel, latest.DateLabel, StringComparison.Ordinal))
|
||||
{
|
||||
date = earliest.DateLabel + "–" + latest.DateLabel;
|
||||
}
|
||||
|
||||
string body = "Slew " + kills + " opponents across repeated clashes";
|
||||
string line = string.IsNullOrEmpty(date) ? body : date + " · " + body;
|
||||
summary = new LifeSagaLegacyBeat
|
||||
{
|
||||
Kind = LifeSagaFactKind.Kill,
|
||||
Line = line,
|
||||
Truth = "observed",
|
||||
At = latest.At,
|
||||
DateLabel = date
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static LifeSagaFactKind NarrativeLegacyKind(NarrativeThread thread)
|
||||
{
|
||||
if (thread == null) return LifeSagaFactKind.Unknown;
|
||||
switch (thread.Kind)
|
||||
{
|
||||
case NarrativeThreadKind.Partnership: return LifeSagaFactKind.BondFormed;
|
||||
case NarrativeThreadKind.Lineage: return LifeSagaFactKind.ParentChild;
|
||||
case NarrativeThreadKind.SeparationGrief: return LifeSagaFactKind.BondBroken;
|
||||
case NarrativeThreadKind.RiseToRule:
|
||||
case NarrativeThreadKind.Reign:
|
||||
case NarrativeThreadKind.Succession: return LifeSagaFactKind.RoleChange;
|
||||
case NarrativeThreadKind.Founding: return LifeSagaFactKind.Founding;
|
||||
case NarrativeThreadKind.HomeLoss: return LifeSagaFactKind.HomeLost;
|
||||
case NarrativeThreadKind.Rivalry: return LifeSagaFactKind.RivalEarned;
|
||||
case NarrativeThreadKind.PersonalCombat: return LifeSagaFactKind.Kill;
|
||||
case NarrativeThreadKind.WarInvolvement: return LifeSagaFactKind.WarJoin;
|
||||
case NarrativeThreadKind.PlotBetrayal: return LifeSagaFactKind.PlotJoin;
|
||||
case NarrativeThreadKind.Transformation:
|
||||
case NarrativeThreadKind.Recovery: return LifeSagaFactKind.Unknown;
|
||||
default: return LifeSagaFactKind.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Living Cast already presents lover/friend/kin - do not echo those names as Legacy crumbs.
|
||||
/// Dead / past relations and wars/plots/kills still belong in Legacy.
|
||||
/// </summary>
|
||||
private static bool CastOwnsLivingRelation(LifeSagaFact fact, HashSet<long> castIds)
|
||||
{
|
||||
if (fact == null || castIds == null || castIds.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long otherId = fact.OtherId != 0 ? fact.OtherId : fact.Other.Id;
|
||||
if (otherId == 0 || !castIds.Contains(otherId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (fact.Kind)
|
||||
{
|
||||
case LifeSagaFactKind.BondFormed:
|
||||
case LifeSagaFactKind.FriendFormed:
|
||||
case LifeSagaFactKind.ParentChild:
|
||||
// Only skip when the cast member is still alive (Cast shows present faces).
|
||||
return EventFeedUtil.FindAliveById(otherId) != null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
model.Legacy.AddRange(
|
||||
LegacyChapterBuilder.Build(unitId, model.Title, stake, castIds, displayLimit: 4));
|
||||
}
|
||||
|
||||
private static string KingdomName(Actor actor)
|
||||
|
|
@ -1012,10 +720,15 @@ public sealed class LifeSagaCastMember
|
|||
|
||||
public sealed class LifeSagaLegacyBeat
|
||||
{
|
||||
public string ChapterId = "";
|
||||
public string SourceEventId = "";
|
||||
public string SourceDevelopmentId = "";
|
||||
public string SourceThreadId = "";
|
||||
public LifeSagaFactKind Kind;
|
||||
public string Line = "";
|
||||
public string Truth = "observed";
|
||||
public float At;
|
||||
public float Importance;
|
||||
public string DateLabel = "";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ public static class LifeSagaRoster
|
|||
}
|
||||
|
||||
s.Prefer = !s.Prefer;
|
||||
NarrativePersistence.NotePreference(s);
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -162,6 +163,7 @@ public static class LifeSagaRoster
|
|||
}
|
||||
|
||||
s.Prefer = prefer;
|
||||
NarrativePersistence.NotePreference(s);
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -2026,6 +2028,8 @@ public static class LifeSagaRoster
|
|||
s.TouchedAt = now;
|
||||
s.Heat = 0f;
|
||||
RefreshSlot(s, actor, now);
|
||||
s.Prefer = NarrativePersistence.IsPreferred(
|
||||
s.UnitId, s.DisplayName, s.SpeciesId);
|
||||
s.AdmissionReason = reason != LifeSagaAdmissionReason.None
|
||||
? reason
|
||||
: InferAdmissionReason(s);
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ public static class LifeSagaSession
|
|||
LifeSagaRail.Clear();
|
||||
LifeSagaPanel.Clear();
|
||||
NarrativeStoryStore.Clear();
|
||||
NarrativePersistence.ResetTransient();
|
||||
_epoch++;
|
||||
|
||||
object world = SafeWorld();
|
||||
|
|
@ -86,6 +87,40 @@ public static class LifeSagaSession
|
|||
public static void OnWorldLoadFinished()
|
||||
{
|
||||
ResetForWorldChange("load-complete");
|
||||
NarrativePersistence.LoadCurrentWorld();
|
||||
}
|
||||
|
||||
/// <summary>Stable save identity: excludes object hashes and load counters.</summary>
|
||||
public static string ComputePersistentKey(object worldObj)
|
||||
{
|
||||
MapBox world = worldObj as MapBox;
|
||||
if (world == null) return "";
|
||||
|
||||
string name = "";
|
||||
string seed = "";
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
try
|
||||
{
|
||||
name = world.map_stats?.name ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
name = "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
seed = MapBox.current_world_seed_id.ToString();
|
||||
width = MapBox.width;
|
||||
height = MapBox.height;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Retain whatever stable fragments were available.
|
||||
}
|
||||
|
||||
return name + "|" + seed + "|" + width + "x" + height;
|
||||
}
|
||||
|
||||
public static string ComputeBindKey(object worldObj)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
|
@ -34,3 +36,36 @@ public static class LifeSagaSessionPatches
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Promote narrative recovery state only after WorldBox confirms a save completed.</summary>
|
||||
[HarmonyPatch]
|
||||
public static class NarrativeSaveCompletionPatch
|
||||
{
|
||||
public static MethodBase TargetMethod()
|
||||
{
|
||||
Type[] types = typeof(MapBox).Assembly.GetTypes();
|
||||
for (int i = 0; i < types.Length; i++)
|
||||
{
|
||||
MethodInfo method = AccessTools.Method(types[i], "finishSaving");
|
||||
if (method != null)
|
||||
{
|
||||
return method;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[HarmonyPostfix]
|
||||
public static void Postfix()
|
||||
{
|
||||
try
|
||||
{
|
||||
NarrativePersistence.SaveCommittedWorld();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Saving the base world must never depend on the narrative sidecar.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,28 +134,6 @@ public static class SagaProse
|
|||
return s;
|
||||
}
|
||||
|
||||
public static string Clause(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string s = raw.Trim().TrimStart('·', ' ').Trim();
|
||||
if (s.Length == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
char first = s[0];
|
||||
if (char.IsLetter(first) && char.IsUpper(first))
|
||||
{
|
||||
return char.ToLowerInvariant(first) + s.Substring(1);
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Identity title for MCs. Empty when no real role (caller falls back to species/job).</summary>
|
||||
public static string Title(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
|
||||
{
|
||||
|
|
@ -362,124 +340,6 @@ public static class SagaProse
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Beat tip suffix without leading " · ". Empty when redundant or unknown.
|
||||
/// tipVerbClass: "combat" | "love" | "grief" | "plot" | "other"
|
||||
/// </summary>
|
||||
public static string BeatClause(
|
||||
Actor focus,
|
||||
Actor other,
|
||||
RelationEvidence e,
|
||||
string tipLabel,
|
||||
string tipVerbClass)
|
||||
{
|
||||
if (e == null || e.Class == RelationClass.None || other == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string tip = tipLabel ?? "";
|
||||
string tipLower = tip.ToLowerInvariant();
|
||||
string verb = (tipVerbClass ?? "other").ToLowerInvariant();
|
||||
|
||||
// Tip-verb class can flip bond vs foe when both apply.
|
||||
RelationClass use = e.Class;
|
||||
if (e.AlsoFoe && verb == "combat" && e.Class == RelationClass.Bond)
|
||||
{
|
||||
use = RelationClass.Foe;
|
||||
}
|
||||
else if (e.AlsoBond && (verb == "love" || verb == "grief") && e.Class == RelationClass.Foe)
|
||||
{
|
||||
use = RelationClass.Bond;
|
||||
}
|
||||
|
||||
switch (use)
|
||||
{
|
||||
case RelationClass.Bond:
|
||||
if (LooksLikeLoveTip(tipLower))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (e.BondBroken)
|
||||
{
|
||||
return verb == "grief" || verb == "love" ? Clause("once their lover") : "";
|
||||
}
|
||||
|
||||
return Clause("their lover");
|
||||
|
||||
case RelationClass.Friend:
|
||||
return e.BestFriend ? Clause("their closest friend") : Clause("their friend");
|
||||
|
||||
case RelationClass.Kin:
|
||||
if (e.KinKind == KinKind.Parent) return Clause("their parent");
|
||||
if (e.KinKind == KinKind.Child) return Clause("their child");
|
||||
if (e.KinKind == KinKind.Heir) return Clause("their heir");
|
||||
if (e.KinKind == KinKind.Packmate) return Clause("a packmate");
|
||||
return Clause("their kin");
|
||||
|
||||
case RelationClass.Foe:
|
||||
{
|
||||
RelationEvidence foe = e.AlsoFoe && e.FoeKind == FoeKind.None
|
||||
? e
|
||||
: e;
|
||||
FoeKind fk = foe.FoeKind != FoeKind.None ? foe.FoeKind : e.FoeKind;
|
||||
int count = foe.RematchCount > 0 ? foe.RematchCount : e.RematchCount;
|
||||
string realm = FirstNonEmpty(foe.RealmOrPlot, e.RealmOrPlot);
|
||||
if (fk == FoeKind.Rematch || count > 0)
|
||||
{
|
||||
if (count >= 2)
|
||||
{
|
||||
return Clause("their " + Ordinal(count) + " clash");
|
||||
}
|
||||
|
||||
return Clause("again and again");
|
||||
}
|
||||
|
||||
if (fk == FoeKind.War)
|
||||
{
|
||||
return string.IsNullOrEmpty(realm)
|
||||
? Clause("an enemy from war")
|
||||
: Clause("enemy from the " + realm + " war");
|
||||
}
|
||||
|
||||
if (fk == FoeKind.Plot)
|
||||
{
|
||||
return string.IsNullOrEmpty(realm)
|
||||
? Clause("a foe in a scheme")
|
||||
: Clause("foe in the " + realm + " scheme");
|
||||
}
|
||||
|
||||
if (fk == FoeKind.Blood)
|
||||
{
|
||||
return Clause("blood feud");
|
||||
}
|
||||
|
||||
return Clause("an old foe");
|
||||
}
|
||||
|
||||
case RelationClass.PlotPartner:
|
||||
{
|
||||
string plot = e.RealmOrPlot;
|
||||
return string.IsNullOrEmpty(plot)
|
||||
? Clause("their plot partner")
|
||||
: Clause("plot partner in " + plot);
|
||||
}
|
||||
|
||||
case RelationClass.Grief:
|
||||
if (tipLower.IndexOf("mourn", StringComparison.Ordinal) >= 0
|
||||
|| tipLower.IndexOf("grief", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return "";
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public static RelationEvidence ResolveRelation(long focusId, long otherId, Actor focus, Actor other)
|
||||
{
|
||||
if (focusId == 0 || otherId == 0 || focusId == otherId)
|
||||
|
|
@ -1131,38 +991,6 @@ public static class SagaProse
|
|||
return note;
|
||||
}
|
||||
|
||||
private static bool LooksLikeLoveTip(string tipLower)
|
||||
{
|
||||
return tipLower.IndexOf("lover", StringComparison.Ordinal) >= 0
|
||||
|| tipLower.IndexOf("love", StringComparison.Ordinal) >= 0
|
||||
|| tipLower.IndexOf("smitten", StringComparison.Ordinal) >= 0
|
||||
|| tipLower.IndexOf("kiss", StringComparison.Ordinal) >= 0
|
||||
|| tipLower.IndexOf("intimacy", StringComparison.Ordinal) >= 0
|
||||
|| tipLower.IndexOf("bound", StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
|
||||
private static string Ordinal(int n)
|
||||
{
|
||||
if (n <= 0)
|
||||
{
|
||||
return "next";
|
||||
}
|
||||
|
||||
int mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 13)
|
||||
{
|
||||
return n + "th";
|
||||
}
|
||||
|
||||
switch (n % 10)
|
||||
{
|
||||
case 1: return n + "st";
|
||||
case 2: return n + "nd";
|
||||
case 3: return n + "rd";
|
||||
default: return n + "th";
|
||||
}
|
||||
}
|
||||
|
||||
private static string KingdomName(Actor actor)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -16,8 +16,14 @@ events, and returns only when the story has new evidence. `EventStrength` still
|
|||
visual value, but it no longer owns continuity. Bounded character/world fill runs when no episode
|
||||
has a usable development.
|
||||
|
||||
While watching, a **character beat caption** shows who they are (Identity), an orange **event sentence** (Beat), and muted Context (story spine or open stake).
|
||||
Main characters get `Name · Title` when they have a real role; tips may gain a self-explanatory relation clause.
|
||||
While watching, a **character beat caption** shows who they are (Identity), an orange **event
|
||||
sentence** (Beat), and muted Context (story spine or confirmed pressure). Main characters get
|
||||
`Name · Title` when they have a real role. The Beat is rendered once from typed event evidence, so
|
||||
relationship details are not appended a second time.
|
||||
|
||||
Confirmed MC events, threads, consequences, Legacy, and Prefer choices are stored in a versioned
|
||||
per-world sidecar at the game save boundary. Runtime camera state and episode timing remain
|
||||
session-only.
|
||||
See [`docs/life-saga.md`](docs/life-saga.md) and [`docs/event-reason.md`](docs/event-reason.md).
|
||||
|
||||
Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps the camera.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ Observe → confirm → emit: [`event-observation.md`](event-observation.md).
|
|||
| **Live pipelines** | `event_live_pipelines` | Traits/decisions/WorldLog/wars/eras/books/plots + libraries (worldLaw/item/subspecies_trait/power/gene/phenotype/meta traits); spell/biome best-effort unsupported; boat/combat best-effort | Auto loops + honest gaps |
|
||||
| **Live outcome smoke** | `event_live_outcome_smoke` | Short family + sample apply | Curated |
|
||||
| **Live coverage ledger** | `event_live_coverage` | Honest `none|feed|pipeline|id_drop|id|…` TSV | Seeded from catalogs; claimed by live runners |
|
||||
| **Semantic presentation** | `narrative_presentation_coverage` | Registry intake is typed; render uses no compatibility lookup or redundant phrase | Automatic through `EventFeedUtil` |
|
||||
| **Canonical narrative ingestion** | `narrative_canonical_ingestion` | Duplicate hooks create one event and one development for each durable family | Extend the recorder matrix |
|
||||
|
||||
## Suite split (weight-aware)
|
||||
|
||||
|
|
@ -87,6 +89,8 @@ Ledger `notes` should record `busy_bypass=ForceLiveEventFeeds` (or the specific
|
|||
./scripts/harness-run.sh event_live_relationship
|
||||
./scripts/harness-run.sh event_live_pipelines
|
||||
./scripts/harness-run.sh event_suite
|
||||
./scripts/harness-run.sh narrative_presentation_coverage
|
||||
./scripts/harness-run.sh narrative_canonical_ingestion
|
||||
./scripts/harness-run.sh regression
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ chooses episode developments before bounded ambient/world fallback. Neither auth
|
|||
|
||||
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
|
||||
|
||||
Orange dossier reason = the owning A event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
|
||||
`CaptionComposer` may append a presentation-only relation clause (` · their 3rd clash`) and show a separate Identity line (`Name · King of X`).
|
||||
Orange dossier reason = the owning A event’s semantic `NarrativePresentationModel` while the scene
|
||||
is active (`InterestDirector.TryGetOwnedReasonCandidate`). `Label` remains the presentability and
|
||||
compatibility input, but `NarrativeRenderer` owns the final Beat. `CaptionComposer` never appends a
|
||||
generic relationship suffix. Identity remains a separate line (`Name · King of X`).
|
||||
Identity titles and stake slogans never enter `InterestCandidate.Label`; `identity_filter` /
|
||||
`IsIdentityWhy` remain selection safety gates.
|
||||
The focused unit must be a tip principal:
|
||||
|
|
@ -108,11 +110,16 @@ Roadmap + shared contract: [`sticky-ensemble-plan.md`](sticky-ensemble-plan.md).
|
|||
|
||||
Shared pieces: `LiveEnsemble` builders, `StickyScoreboard`, `EventReason.Ensemble`.
|
||||
|
||||
## EventReason
|
||||
## Semantic presentation
|
||||
|
||||
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.
|
||||
It lives in the Events module with catalogs (not in the director).
|
||||
Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply(template, a, b, id)`.
|
||||
[`IdleSpectator/Narrative/NarrativePresentation.cs`](../IdleSpectator/Narrative/NarrativePresentation.cs)
|
||||
defines the typed presentation contract and is the final orange-Beat voice owner. Confirmed durable
|
||||
events receive their exact event/development presentation from `NarrativeEventRecorder`. Ambient,
|
||||
ensemble, status, and world candidates receive a typed envelope at `EventFeedUtil.Register`.
|
||||
|
||||
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) remains a catalog
|
||||
template/compatibility facade. Feeds may use its helpers to provide authored semantic input, but
|
||||
downstream code must not parse that English to discover actors, relationships, or developments.
|
||||
|
||||
## Events module layout
|
||||
|
||||
|
|
@ -121,7 +128,9 @@ Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply
|
|||
| `Events/EventCatalog.cs` (+ `Catalogs/EventCatalog.*.cs`) | **One inventory** - nested domains + `IsCameraWorthy` |
|
||||
| `Events/Feeds/` | Build + register **A** candidates from catalog dials |
|
||||
| `Events/Patches/` | Harmony hooks → feeds / Activity |
|
||||
| `Events/EventReason.cs` | Orange reason sentences |
|
||||
| `Narrative/NarrativePresentation.cs` | Typed presentation + final orange Beat rendering |
|
||||
| `Narrative/NarrativeEventRecorder.cs` | Stable confirmed event/development receipt |
|
||||
| `Events/EventReason.cs` | Catalog-authored presentation input / compatibility labels |
|
||||
| `Events/LiveEnsemble.cs` | Multi-actor snapshot + combat builders |
|
||||
| `Events/LiveSceneStickyState.cs` | Durable sticky scoreboard on a candidate |
|
||||
| `Events/StickyScoreboard.cs` | Lock / enroll / refresh / promote / scale-hold |
|
||||
|
|
@ -159,10 +168,13 @@ See `scoring-model.json`.
|
|||
|
||||
## Dossier display
|
||||
|
||||
`UnitDossier.EventLabelBeat` keeps EventReason / `Name verb …` sentences.
|
||||
`UnitDossier.EventLabelBeat` keeps the owned reason presentable; `CaptionComposer` prefers the
|
||||
candidate's typed presentation and exact development id.
|
||||
It rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`).
|
||||
Active EventLed A scenes should not silently blank a valid Label.
|
||||
`EventPresentability.WouldShow` is the shared gate so unpresentable Labels never win Watching.
|
||||
`narrative_presentation_coverage` is the registry/rendering gate: structured intake, no
|
||||
compatibility lookup, and no semantic redundancy.
|
||||
|
||||
## See also
|
||||
|
||||
|
|
|
|||
|
|
@ -3,21 +3,28 @@
|
|||
Dynamic top-10 cast of interesting lives (MCs). Owns the dossier rail and soft camera bias.
|
||||
Chronicle stays a history book and never drives the camera.
|
||||
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
|
||||
Durable observed facts live in [`LifeSagaMemory`](../IdleSpectator/Story/LifeSagaMemory.cs) and survive roster churn.
|
||||
Confirmed observations enter through
|
||||
[`NarrativeEventRecorder`](../IdleSpectator/Narrative/NarrativeEventRecorder.cs); `LifeSagaMemory`
|
||||
is the compatibility/read projection and survives roster churn.
|
||||
The fixed-layout hover redesign is specified in the [Saga UX redesign plan](saga-ux-redesign-plan.md).
|
||||
|
||||
AFK caption is a **character beat**: Identity (who) + Beat (live tip) + Context (spine or stake).
|
||||
[`CaptionComposer`](../IdleSpectator/Story/CaptionComposer.cs) merges saga identity into the live caption without baking titles into `InterestCandidate.Label`.
|
||||
[`SagaProse`](../IdleSpectator/Story/SagaProse.cs) owns all player-facing relation/stake English.
|
||||
[`NarrativeRenderer`](../IdleSpectator/Narrative/NarrativePresentation.cs) owns complete orange
|
||||
Beats. [`SagaProse`](../IdleSpectator/Story/SagaProse.cs) owns saga identity, Cast, and stake voice.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|---------|--------|
|
||||
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
|
||||
| Durable observed facts | `LifeSagaMemory` |
|
||||
| Confirmed event identity and idempotency | `NarrativeEventRecorder` + `NarrativeEventStore` |
|
||||
| Durable projection/index facade | `LifeSagaMemory` + `NarrativeStoryStore` |
|
||||
| Versioned per-world persistence | `NarrativePersistence` + `NarrativePersistenceMapper` |
|
||||
| Adaptive presentation model | `LifeSagaPresentation` |
|
||||
| Player-facing saga/caption English | `SagaProse` |
|
||||
| Orange Beat English | `NarrativeRenderer` |
|
||||
| Saga Identity/Cast/stake English | `SagaProse` |
|
||||
| Legacy chapters | `LegacyChapterBuilder` |
|
||||
| Identity + Beat + Context caption | `CaptionComposer` → `WatchCaption` |
|
||||
| Hover Cast/Legacy depth | `LifeSagaViewController` + `LifeSagaPanel` |
|
||||
| Rail icons / Prefer click | `LifeSagaRail` |
|
||||
|
|
@ -71,13 +78,14 @@ Always-on AFK chrome (no Dossier/Saga tabs):
|
|||
| Row | Source | MC | Nobody |
|
||||
|-----|--------|----|--------|
|
||||
| Identity | `CaptionComposer` | `Name · Title` (real roles only; else species/job) | `Name (Species/Job)` |
|
||||
| Beat | owned tip + `SagaProse.BeatClause` | tip enriched with one relation clause | tip as today |
|
||||
| Beat | candidate `NarrativePresentationModel` | one complete semantic sentence | one complete semantic sentence |
|
||||
| Context | muted | spine if live short-arc; else unresolved stake hitch (omits kill/role/founding under love/rest tips; RoleChange that echoes Identity always omitted) | spine only |
|
||||
|
||||
Hard rule: saga titles and stake slogans never enter `InterestCandidate.Label`.
|
||||
The story scheduler owns selection; `identity_filter` remains a safety gate against presenting
|
||||
identity text as a development.
|
||||
Beat other resolves `RelatedId` then `PairPartnerId` (no living-name parse).
|
||||
Beat participants come from the exact presentation/event receipt (no latest-similar lookup and no
|
||||
living-name parse).
|
||||
Status banners still own/suppress Beat; Identity + Context keep updating.
|
||||
Outside hover, caption Identity/Beat/Context track camera focus. During Saga hover, the
|
||||
primary nametag temporarily binds to the hovered MC (species, name/title, sex, favorite),
|
||||
|
|
@ -110,7 +118,8 @@ running underneath and restores immediately when hover ends.
|
|||
|
||||
## Saga prose
|
||||
|
||||
`SagaProse` is the single voice owner. Evidence drives copy; enum names never appear in UI.
|
||||
`SagaProse` is the saga Identity/Cast/stake voice owner. `NarrativeRenderer` separately owns the
|
||||
complete orange Beat. Evidence drives both; enum names never appear in UI.
|
||||
|
||||
Relation classes: Bond, Friend, Kin (Parent/Child/Heir/Packmate/Kin), Foe (Old Foe/War Enemy/Plot Enemy/Blood Foe), Plot Partner, Grief.
|
||||
|
||||
|
|
@ -119,10 +128,22 @@ Ban list (player-facing): `earned rival`, `Earned a rival`, `Rivalry with`, `sag
|
|||
Capitalization:
|
||||
|
||||
- Identity titles / Cast labels: Title Case (`Pack Alpha`, `Old Foe`, `Best Friend`)
|
||||
- Beat clauses after ` · `: lowercase start
|
||||
- Orange Beats: Sentence case, rendered as one complete phrase
|
||||
- Stake / Legacy: Sentence case
|
||||
- Unit names and owned tip body: leave as-is
|
||||
|
||||
## Durable narrative state
|
||||
|
||||
Schema-v1 per-world sidecars persist semantic events, developments, threads, consequences,
|
||||
identity snapshots, and Prefer choices. Writes are atomic and only promoted at a confirmed game
|
||||
save completion. Load rejects corrupt/newer schemas safely, preserves unreadable data, ignores
|
||||
facts newer than the loaded world time, and rebuilds live actor bindings without replaying restored
|
||||
events as fresh camera developments. Camera focus, active episodes, cooldowns, and session time are
|
||||
never serialized.
|
||||
|
||||
Legacy is authored only by `LegacyChapterBuilder`: 3+ births consolidate into lineage, repeated
|
||||
kills consolidate into a combat chapter, and current Cast/Identity facts do not echo unchanged.
|
||||
|
||||
## Opposition facts (memory)
|
||||
|
||||
Notable only:
|
||||
|
|
|
|||
756
docs/narrative-coherence-and-persistence-plan.md
Normal file
756
docs/narrative-coherence-and-persistence-plan.md
Normal file
|
|
@ -0,0 +1,756 @@
|
|||
# Narrative coherence and durable Legacy plan
|
||||
|
||||
## Status
|
||||
|
||||
**Implemented — phases 0–8 complete (2026-07-23).**
|
||||
|
||||
Release evidence: clean NML restart; 20/20 full regression scenarios; semantic presentation
|
||||
coverage at `intake=1/0`, `compat=0`, `redundant=0`; 10/10 canonical event-family idempotency
|
||||
matrix; persistence round-trip and corrupt/newer-schema recovery; Legacy lineage/combat
|
||||
consolidation; episode grammar and ensemble balance; relationship live suite 3/3; seeded and
|
||||
20-second natural story soaks. The persistence format and real save-complete/load-complete hooks
|
||||
are integrated; automated verification intentionally uses isolated DTO round-trips rather than
|
||||
overwriting a player's WorldBox save.
|
||||
|
||||
This is the successor to
|
||||
[`mc-story-director-rewrite-plan.md`](mc-story-director-rewrite-plan.md). The story-first
|
||||
director and current Saga UI are the foundation; this plan does not reopen that cutover or redesign
|
||||
the HUD.
|
||||
|
||||
The work is complete only when:
|
||||
|
||||
- every selected orange reason is rendered once from semantic evidence;
|
||||
- each observed game event enters narrative state through one idempotent writer;
|
||||
- captions, threads, Cast, and Legacy refer to the same event identity;
|
||||
- durable MC stories survive a real save/reload cycle;
|
||||
- episodes communicate a deliberate development rather than only selecting the highest related
|
||||
score; and
|
||||
- the old label-concatenation, duplicate-routing, and raw-fact Legacy fallbacks are removed.
|
||||
|
||||
## Product outcome
|
||||
|
||||
IdleSpectator should feel like an automatically edited chronicle of a small ensemble.
|
||||
|
||||
For every authored cut, the player should be able to understand:
|
||||
|
||||
1. **Who is this about?**
|
||||
2. **What changed?**
|
||||
3. **Who or what was involved?**
|
||||
4. **Why is this development part of the current story?**
|
||||
5. **What lasting consequence belongs in this life’s Legacy?**
|
||||
|
||||
The same confirmed observation must answer all five questions. Camera captions and Legacy may use
|
||||
different tense and detail, but they must not invent separate versions of the event.
|
||||
|
||||
## Problems this plan resolves
|
||||
|
||||
### Multiple prose owners
|
||||
|
||||
An `InterestCandidate.Label` may already say `Gains a child, Omyahel`, after which
|
||||
`CaptionComposer.EnrichBeat` asks `SagaProse.BeatClause` to add `their child`. Both pieces are
|
||||
individually reasonable, but together they repeat the same relationship.
|
||||
|
||||
The structured `NarrativeProse` path avoids this for a small set of event ids. Most event families
|
||||
still depend on authored labels, feed-local string construction, or generic relationship suffixes.
|
||||
|
||||
### Multiple fact writers
|
||||
|
||||
Several Harmony patches call both `LifeSagaMemory.Record...` and
|
||||
`NarrativeDevelopmentRouter...`, while the memory helper also calls the router. Store idempotency
|
||||
limits visible damage, but ownership is ambiguous and the system attempts to emit the same semantic
|
||||
change more than once.
|
||||
|
||||
### Session-only stories
|
||||
|
||||
`LifeSagaSession.ResetForWorldChange` currently clears Saga memory and the narrative store after a
|
||||
map load. This is safe, but it makes “Legacy” a session summary rather than a durable world history.
|
||||
|
||||
### Related-shot selection is not yet episode composition
|
||||
|
||||
`StoryScheduler` owns story-first selection and tracks episode purpose, but
|
||||
`EpisodeShotSelector` still chooses mainly through one editorial score. It does not yet require the
|
||||
episode to cover distinct editorial roles such as establishment, change, pressure, and consequence.
|
||||
|
||||
### Legacy has overlapping sources
|
||||
|
||||
Legacy currently combines narrative chapters, selected `LifeSagaFact` records, special combat
|
||||
summaries, and presentation-layer suppression rules. It works, but its correctness depends on
|
||||
several layers agreeing about duplicates.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new tabs, dossier layout, or rail redesign.
|
||||
- No return to event-first camera ownership.
|
||||
- No invented emotions, motives, causal claims, victories, heirs, or rivalries.
|
||||
- No forced establishing shots when presentable footage does not exist.
|
||||
- No persistence of live `Actor` references, camera position, current combat phase, or active
|
||||
episode timing.
|
||||
- No permanent compatibility setting for the old prose or old narrative writer.
|
||||
- No attempt to turn every ambient activity into a durable life event.
|
||||
|
||||
## Target architecture
|
||||
|
||||
```text
|
||||
Confirmed game observation
|
||||
|
|
||||
v
|
||||
NarrativeEventRecorder -- stable id + idempotency + provenance
|
||||
|
|
||||
+--> immutable NarrativeEventRecord (one world fact)
|
||||
|
|
||||
+--> character-perspective NarrativeDevelopment projections
|
||||
| +--> threads / consequences / story potential
|
||||
| +--> durable per-life indexes
|
||||
|
|
||||
+--> NarrativeEventReceipt
|
||||
+--> InterestCandidate semantic presentation + exact development id
|
||||
|
||||
InterestCandidate --> StoryScheduler --> camera cut
|
||||
--> NarrativeRenderer --> orange Beat / Context
|
||||
|
||||
NarrativeEventRecord + threads + consequences
|
||||
--> LegacyChapterBuilder --> Legacy
|
||||
|
||||
Durable records / identities / threads / preferences
|
||||
<--> versioned world narrative save
|
||||
```
|
||||
|
||||
### One observation, multiple perspectives
|
||||
|
||||
A world event is recorded once. It may produce more than one character-perspective development.
|
||||
|
||||
Example: a birth creates one `NarrativeEventRecord`, then:
|
||||
|
||||
- the parent projection can render `Welcomes first child Omyahel`;
|
||||
- the child projection can render `Is born to Joon`; and
|
||||
- a partner projection may be created only when a confirmed parent/partner relationship supports it.
|
||||
|
||||
These projections share the same event id. They are not mirrored copies pretending to be separate
|
||||
world events.
|
||||
|
||||
## Core contracts
|
||||
|
||||
### 1. One fact, one writer
|
||||
|
||||
Every confirmed narrative observation enters through `NarrativeEventRecorder.Record`. Callers do
|
||||
not separately write Saga memory, narrative developments, consequences, or thread state.
|
||||
|
||||
### 2. One selected event, one presentation model
|
||||
|
||||
Every selected candidate carries an exact semantic presentation reference. Caption composition
|
||||
must not search for “the latest similar event” and must not infer the related actor from prose.
|
||||
|
||||
### 3. One semantic fact, one phrase
|
||||
|
||||
The orange Beat is rendered as a complete sentence by one renderer. Relationship information is
|
||||
included only when it contributes a fact not already expressed by the action.
|
||||
|
||||
### 4. Context adds information
|
||||
|
||||
Context may name confirmed pressure, an unresolved question, or a consequence. It must not
|
||||
paraphrase the Beat, repeat Identity, or expose scheduler terminology.
|
||||
|
||||
### 5. Runtime and durable time are different
|
||||
|
||||
- World date/tick orders persistent facts and supplies display dates.
|
||||
- Session monotonic time controls cooldowns, holds, momentum decay, and active episode timing.
|
||||
- Session timestamps are never serialized as durable chronology.
|
||||
|
||||
### 6. Persistence is fail-safe
|
||||
|
||||
Missing, corrupt, or newer-version narrative data must not prevent a world from loading. The mod
|
||||
logs the problem, preserves the unreadable file when possible, starts an empty narrative state, and
|
||||
continues operating.
|
||||
|
||||
### 7. Episode grammar never fabricates footage
|
||||
|
||||
The scheduler may wait, hand off, or close an episode when a needed role has no presentable
|
||||
candidate. It may not manufacture a development just to complete a planned sequence.
|
||||
|
||||
## Proposed data model
|
||||
|
||||
### `NarrativeEventRecord`
|
||||
|
||||
Immutable world observation:
|
||||
|
||||
- schema version;
|
||||
- stable event id;
|
||||
- semantic event kind;
|
||||
- subject, other, and participant identity snapshots;
|
||||
- relationship/evidence roles rather than player-facing relation text;
|
||||
- correlation, family, city, kingdom, war, and plot keys;
|
||||
- previous state, new state, and confirmed outcome;
|
||||
- world tick/date;
|
||||
- evidence source and confidence;
|
||||
- magnitude and presentability;
|
||||
- ingestion provenance and dedupe key.
|
||||
|
||||
### `NarrativeDevelopment`
|
||||
|
||||
Character-perspective projection:
|
||||
|
||||
- event id;
|
||||
- perspective character id;
|
||||
- development kind and narrative function;
|
||||
- other character/participants in that perspective;
|
||||
- thread id when assigned;
|
||||
- confirmed change/outcome;
|
||||
- session-observed time for runtime scheduling;
|
||||
- world tick/date for durable ordering.
|
||||
|
||||
Existing `NarrativeDevelopment` fields should be migrated rather than duplicated where possible.
|
||||
`SubjectId` becomes the perspective owner explicitly instead of relying on an implied viewpoint.
|
||||
|
||||
### `NarrativePresentationModel`
|
||||
|
||||
Player-facing semantic input, containing no final English:
|
||||
|
||||
- event/development id;
|
||||
- presentation kind and tense;
|
||||
- perspective id;
|
||||
- primary and secondary identity snapshots;
|
||||
- evidence-backed relationship role;
|
||||
- ordinal/count when confirmed;
|
||||
- change and outcome tokens;
|
||||
- ensemble side snapshots when applicable;
|
||||
- confidence and provenance.
|
||||
|
||||
The model is attached to or referenced by `InterestCandidate`. Dynamic ensemble cards may retain a
|
||||
typed ensemble presentation rather than becoming durable character developments.
|
||||
|
||||
### `NarrativeEventReceipt`
|
||||
|
||||
Returned by the recorder:
|
||||
|
||||
- whether this was a new event or a duplicate observation;
|
||||
- stable event id;
|
||||
- development ids by perspective;
|
||||
- semantic presentation model for the emitting candidate;
|
||||
- any rejection or confidence reason.
|
||||
|
||||
### Persistence DTOs
|
||||
|
||||
Use separate plain DTOs rather than serializing runtime dictionaries or Unity/game objects.
|
||||
|
||||
Persist:
|
||||
|
||||
- durable event records within retention limits;
|
||||
- identity snapshots;
|
||||
- character story indexes;
|
||||
- open/resolved threads and consequences;
|
||||
- authored Legacy chapter state if it cannot be deterministically rebuilt;
|
||||
- Saga Prefer choices;
|
||||
- schema version, stable world key, and last durable world tick.
|
||||
|
||||
Rebuild after load:
|
||||
|
||||
- dictionaries and reverse indexes;
|
||||
- effective story potential and runtime decay anchors;
|
||||
- live actor bindings;
|
||||
- current Saga ranking;
|
||||
- captions, active episode, cooldowns, and camera state.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
| Phase | Primary outcome | Risk |
|
||||
|---|---|---|
|
||||
| 0 | Measurable baseline and explainable cuts | Low |
|
||||
| 1 | Relationship/lineage captions rendered once | Medium |
|
||||
| 2 | One canonical, idempotent narrative writer | High |
|
||||
| 3 | Complete typed orange-reason coverage | High |
|
||||
| 4 | One semantic Legacy chapter pipeline | Medium |
|
||||
| 5 | Save/reload durability and recovery | Very high |
|
||||
| 6 | Deliberate episode grammar | High |
|
||||
| 7 | Long-horizon ensemble balance | Medium |
|
||||
| 8 | Old-path deletion and release proof | Medium |
|
||||
|
||||
The order is intentional. The visible caption defect is fixed first, canonical ingestion then
|
||||
removes its root ownership ambiguity, and persistence begins only after the in-memory facts and
|
||||
chapters have one stable shape. Scheduler behavior changes after persistence so camera tuning
|
||||
cannot hide state migration defects.
|
||||
|
||||
## Phase 0 — Baseline, inventory, and observability
|
||||
|
||||
### Work
|
||||
|
||||
1. Capture the current full regression result and a natural story-first soak as the comparison
|
||||
baseline.
|
||||
2. Add a machine-readable inventory of candidate families and current prose provenance:
|
||||
structured narrative, `EventReason`, feed-authored label, ensemble renderer, or compatibility
|
||||
fallback.
|
||||
3. Add selection telemetry for:
|
||||
- candidate key and asset id;
|
||||
- event/development id, when present;
|
||||
- prose path;
|
||||
- perspective and related ids;
|
||||
- thread and episode purpose;
|
||||
- whether Beat, Context, or relation semantics overlap.
|
||||
4. Add a deterministic reproduction of:
|
||||
`Gains a child, Omyahel · their child`.
|
||||
5. Add a harness snapshot that explains one selected cut from observation through rendered caption.
|
||||
|
||||
### New harness targets
|
||||
|
||||
- `narrative_prose_redundancy`
|
||||
- `narrative_cut_explain`
|
||||
- `narrative_prose_coverage`
|
||||
|
||||
### Exit gate
|
||||
|
||||
- Existing regression remains green.
|
||||
- The child redundancy test fails for the expected semantic reason before the fix.
|
||||
- Every naturally selected authored cut reports its prose provenance.
|
||||
|
||||
## Phase 1 — Semantic presentation foundation and relationship cutover
|
||||
|
||||
This phase fixes the reported orange-reason problem first while establishing the model used by the
|
||||
remaining migrations.
|
||||
|
||||
### Work
|
||||
|
||||
1. Add `NarrativePresentationModel`, explicit viewpoint, and a renderer that owns the complete Beat.
|
||||
2. Stamp an exact event/development reference onto `InterestCandidate`; stop using
|
||||
`NarrativeStoryStore.LatestFor(..., 8f)` when an exact id exists.
|
||||
3. Implement semantic rendering for:
|
||||
- lover formed/broken;
|
||||
- friend formed;
|
||||
- parent/child and birth;
|
||||
- death and grief with confirmed relationship;
|
||||
- gifts or relationship interactions that name both actors.
|
||||
4. Encode relationship roles as data. Do not derive them from already-rendered text.
|
||||
5. Add semantic novelty checks:
|
||||
- a relation descriptor is omitted when the action already states it;
|
||||
- ordinal detail such as `first child` is allowed because it adds information;
|
||||
- names appear once within a Beat;
|
||||
- Context cannot repeat the Beat’s event, relationship, role, or outcome tags.
|
||||
6. Structured Beats bypass `CaptionComposer.EnrichBeat`.
|
||||
7. Keep the legacy label path only for unmigrated event families during this phase.
|
||||
|
||||
### Required prose matrix
|
||||
|
||||
Test each applicable event with:
|
||||
|
||||
- named and unnamed related actor;
|
||||
- living and dead related actor;
|
||||
- MC as subject and MC as related actor;
|
||||
- parent and child viewpoints;
|
||||
- first, second, and 3+ child;
|
||||
- current and former lover;
|
||||
- best friend and ordinary friend;
|
||||
- missing/stale actor binding with a valid identity snapshot;
|
||||
- status-banner ownership;
|
||||
- re-entry caption;
|
||||
- Beat plus thread Context.
|
||||
|
||||
### Exit gate
|
||||
|
||||
- No relationship or lineage Beat repeats its action, relationship, or related name.
|
||||
- All relationship candidates bind to the exact development they display.
|
||||
- No viewpoint reversal in the deterministic matrix.
|
||||
- Existing caption, relationship, Legacy, and presentability scenarios pass.
|
||||
|
||||
## Phase 2 — Canonical event ingestion and single-writer cutover
|
||||
|
||||
### Work
|
||||
|
||||
1. Introduce `NarrativeEventRecorder` and `NarrativeEventReceipt`.
|
||||
2. Define stable dedupe keys from semantic identity and durable world chronology. Use a
|
||||
source-instance/correlation token when the game exposes one; otherwise use a bounded
|
||||
family-specific dedupe window so two legitimate same-kind events are not collapsed. Do not use
|
||||
final prose or session-only `Time.unscaledTime` as persistent identity.
|
||||
3. Make the recorder transaction:
|
||||
- validate evidence;
|
||||
- record or identify the event;
|
||||
- create perspective developments;
|
||||
- apply thread rules;
|
||||
- create consequences;
|
||||
- update per-life indexes;
|
||||
- return candidate presentation metadata.
|
||||
4. Convert `LifeSagaMemory` into a read/index/projection facade. Its public `Record...` helpers
|
||||
either delegate once to the recorder during migration or are removed.
|
||||
5. Remove direct `NarrativeDevelopmentRouter...` calls from patches that already record the same
|
||||
observation.
|
||||
6. Migrate ingestion by family:
|
||||
|
||||
| Order | Family | Representative sources |
|
||||
|---|---|---|
|
||||
| 1 | Relationship and lineage | `ActorChroniclePatches`, `RelationshipEventPatches`, relationship feed |
|
||||
| 2 | Death, kill, rivalry, combat outcome | actor patches, combat/story hooks |
|
||||
| 3 | Role, founding, home gain/loss | roster deltas, city/kingdom hooks |
|
||||
| 4 | War and plot | war/plot feeds and lifecycle patches |
|
||||
| 5 | Transformation and recovery | status/happiness routers |
|
||||
| 6 | World-context events | disasters, eras, discoveries, civic changes |
|
||||
|
||||
7. Preserve observation-before-confirmation rules: a behavior return value alone is not semantic
|
||||
success.
|
||||
8. Add bounded duplicate-observation telemetry by event family and provenance.
|
||||
|
||||
### New harness targets
|
||||
|
||||
- `narrative_ingestion_idempotent`
|
||||
- `narrative_ingestion_perspectives`
|
||||
- `narrative_hook_single_writer`
|
||||
- `narrative_event_candidate_alignment`
|
||||
|
||||
### Exit gate
|
||||
|
||||
- One game observation produces one event record regardless of how many patched surfaces observe it.
|
||||
- Expected character projections are created once.
|
||||
- No production patch calls both memory recording and the development router.
|
||||
- Candidate, caption, consequence, and thread all report the same event id.
|
||||
- Story potential and Legacy counts do not inflate under duplicate hook invocation.
|
||||
|
||||
## Phase 3 — Complete semantic orange-reason migration
|
||||
|
||||
### Work
|
||||
|
||||
Migrate all remaining selected candidate families to typed presentation:
|
||||
|
||||
| Family | Rendering rule |
|
||||
|---|---|
|
||||
| Role and succession | Describe the confirmed role transition; do not repeat Identity |
|
||||
| Founding and home | Name the confirmed home/settlement when known |
|
||||
| Combat and rivalry | Separate live action, decisive outcome, and earned rivalry |
|
||||
| War and plot | Use confirmed sides/phase/outcome; avoid invented victory or betrayal |
|
||||
| Disaster and loss | State the observed impact, not an assumed emotion or cause |
|
||||
| Status and transformation | Distinguish application, ongoing condition, recovery, and completion |
|
||||
| Family/war/plot/combat ensembles | Keep typed side/count presentation owned by the ensemble renderer |
|
||||
| Milestones and discoveries | State the concrete first/change |
|
||||
| Ambient texture | Use concise authored semantic templates; do not create durable consequences |
|
||||
| Meta/world context | Preserve useful world-scale subjects without pretending they are MC developments |
|
||||
|
||||
Additional work:
|
||||
|
||||
1. Make `EventReason` a compatibility facade over typed rendering where it remains useful; prevent
|
||||
feeds from constructing selected prose ad hoc.
|
||||
2. Replace string-based verb/relation classification with presentation enums/tags.
|
||||
3. Remove generic `SagaProse.BeatClause` concatenation from caption composition. Relation evidence
|
||||
remains useful as semantic input to the renderer.
|
||||
4. Add a safe unknown-event presentation that never appends inferred relationship prose.
|
||||
5. Audit capitalization, punctuation, tense, names, raw ids, placeholder tokens, and missing actor
|
||||
behavior across the entire catalog.
|
||||
6. Track structured coverage at registry intake and at actual selection.
|
||||
|
||||
### Exit gate
|
||||
|
||||
- 100% of selected authored candidates use a typed renderer or a typed ensemble renderer.
|
||||
- No selected candidate uses a feed-local final sentence.
|
||||
- No raw asset id, enum name, placeholder, or duplicated semantic tag reaches the HUD.
|
||||
- The generic label-plus-relation-suffix path is deleted.
|
||||
- Catalog prose audit and full presentability suite pass.
|
||||
|
||||
## Phase 4 — Unified Legacy chapter authoring
|
||||
|
||||
### Work
|
||||
|
||||
1. Introduce `LegacyChapterBuilder` as the only Legacy author.
|
||||
2. Build chapters from semantic events, consequences, and resolved/meaningfully changed threads.
|
||||
3. Replace presentation-time competition between:
|
||||
- narrative chapters;
|
||||
- raw `LifeSagaFact` lines;
|
||||
- roster-stamped prose chapters; and
|
||||
- special combat prose.
|
||||
4. Keep thread-specific merge policy:
|
||||
- partnership formation and loss remain distinct identity changes;
|
||||
- 3+ births consolidate into a lineage chapter;
|
||||
- repeated kills consolidate into one combat chapter;
|
||||
- an earned rivalry remains distinct from a kill count;
|
||||
- role gain/loss may form one reign chapter when both are known;
|
||||
- war/plot chapters prefer confirmed outcome over entry notification;
|
||||
- living Cast relationships do not echo in Legacy unless a historical transition matters.
|
||||
5. Rank chapters by consequence importance, narrative completeness, recency, and diversity—not raw
|
||||
event volume.
|
||||
6. Preserve more semantic chapters than the two-line panel can display; presentation chooses the
|
||||
best two without destroying history.
|
||||
7. Ensure a deceased or currently unbound character’s Legacy renders entirely from snapshots.
|
||||
|
||||
### New harness targets
|
||||
|
||||
- `narrative_legacy_chapters`
|
||||
- `narrative_legacy_lineage`
|
||||
- `narrative_legacy_role_arc`
|
||||
- `narrative_legacy_war_outcome`
|
||||
- `narrative_legacy_snapshot_only`
|
||||
|
||||
### Exit gate
|
||||
|
||||
- Each displayed Legacy line traces to one chapter id and its source event/thread ids.
|
||||
- Cast, current Context, and Legacy do not repeat the same unchanged fact.
|
||||
- Existing combat consolidation remains correct.
|
||||
- Raw-fact prose fallback is removed from production Legacy rendering.
|
||||
|
||||
## Phase 5 — Durable narrative persistence
|
||||
|
||||
### Phase 5A: integration spike
|
||||
|
||||
Before writing the format:
|
||||
|
||||
1. Identify the most reliable WorldBox/NML save-start, save-complete, and load-complete hooks.
|
||||
2. Determine a stable world/save identity that survives process restart. The current runtime bind
|
||||
key contains object identity and is unsuitable for durable storage.
|
||||
3. Confirm the supported mod-data location and atomic replace behavior on the target platforms.
|
||||
4. Verify actor ids across a real save/reload and document cases where ids are recycled or absent.
|
||||
|
||||
Default implementation: a versioned sidecar in the supported mod-data location, keyed by stable
|
||||
save/world identity. If the game exposes a reliable per-save mod-data container, prefer that after
|
||||
the spike.
|
||||
|
||||
### Phase 5B: format and lifecycle
|
||||
|
||||
1. Add a versioned `NarrativeSaveState` DTO and explicit mapper.
|
||||
2. Serialize only durable fields and world chronology.
|
||||
3. Write atomically through temporary file plus replace; keep one last-known-good backup.
|
||||
4. Tag each snapshot with its confirmed save generation/fingerprint and durable world tick.
|
||||
5. Debounce dirty writes, but promote a snapshot to durable state only at a confirmed world-save
|
||||
boundary. A transition or clean-shutdown flush may preserve a recovery snapshot, but it must not
|
||||
silently become the history of an older game save.
|
||||
6. On load, select the snapshot matching the loaded save generation. Never apply narrative events
|
||||
newer than the loaded world tick; preserve unmatched newer data as a separate recovery
|
||||
generation.
|
||||
7. On load:
|
||||
- clear runtime state;
|
||||
- read and validate the matching world state;
|
||||
- rehydrate indexes;
|
||||
- rebind living actors;
|
||||
- retain missing/dead actors as snapshot-only history;
|
||||
- rebase runtime freshness from durable world time;
|
||||
- resolve or abandon threads whose evidence no longer exists;
|
||||
- rebuild roster ranking and clear the active episode.
|
||||
8. Add retention limits by character/event importance and test maximum expected file size. Events
|
||||
referenced by retained threads, consequences, or Legacy chapters cannot be pruned; compact them
|
||||
into a durable semantic chapter record before their detailed event payload is removed.
|
||||
9. Add schema migration support beginning with version 1. Unknown newer schemas load empty without
|
||||
overwriting the unreadable data.
|
||||
10. Persist Saga Prefer by stable character/world identity. Do not persist transient rail order.
|
||||
|
||||
### New harness and live targets
|
||||
|
||||
- `narrative_persistence_roundtrip`
|
||||
- `narrative_persistence_corrupt`
|
||||
- `narrative_persistence_unknown_version`
|
||||
- `narrative_persistence_missing_actor`
|
||||
- `narrative_persistence_no_replay`
|
||||
- a real save, process restart, and reload check
|
||||
|
||||
### Exit gate
|
||||
|
||||
- Relationships, births, deaths, threads, consequences, Legacy, and Prefer survive a real restart.
|
||||
- Reload does not duplicate events or replay old changes as new camera developments.
|
||||
- A corrupt or mismatched sidecar cannot block game load.
|
||||
- Snapshot-only Legacy remains readable when an actor cannot be rebound.
|
||||
- Persistence adds no recurring full-world scan and no visible save hitch.
|
||||
|
||||
## Phase 6 — Editorial episode grammar
|
||||
|
||||
### Data additions
|
||||
|
||||
Add:
|
||||
|
||||
- `EpisodeShotRole`: Establish, Development, Pressure, TurningPoint, Payoff, Consequence,
|
||||
Reintroduction, WorldContext;
|
||||
- covered/pending role ledger;
|
||||
- recent semantic event/action keys;
|
||||
- non-progress and repeated-subject counters;
|
||||
- explicit episode completion reason;
|
||||
- per-thread coverage debt and last-shown world time.
|
||||
|
||||
### Work
|
||||
|
||||
1. Assign shot roles from semantic development/function and current episode state.
|
||||
2. Define a minimal grammar by `EpisodePurpose`:
|
||||
|
||||
| Purpose | Desired communication |
|
||||
|---|---|
|
||||
| Establish | protagonist/cast plus the confirmed opening change |
|
||||
| Develop | new pressure or state change |
|
||||
| Payoff | turning point or confirmed outcome |
|
||||
| Consequence | what the outcome changed for the protagonist |
|
||||
| Reintroduce | identity/context plus a genuinely new development |
|
||||
|
||||
3. Apply eligibility constraints before scalar score:
|
||||
- the shot must advance an uncovered role or be a permitted interrupt;
|
||||
- identical semantic action/subject combinations cool down;
|
||||
- texture cannot satisfy episode progress;
|
||||
- routine combat remains within its existing budget;
|
||||
- critical world events can interrupt and the episode may later resume.
|
||||
4. Use thread coverage debt to favor stories that have developed off-camera.
|
||||
5. End or hand off when:
|
||||
- the information goal is met and a settle beat has elapsed;
|
||||
- no presentable development arrives before expiry;
|
||||
- the thread resolves or becomes invalid;
|
||||
- a stronger coherent episode has materially greater coverage debt.
|
||||
6. Log why each selected shot satisfied the episode and which role remains uncovered.
|
||||
|
||||
### New harness targets
|
||||
|
||||
- `narrative_episode_grammar`
|
||||
- `narrative_episode_no_forced_footage`
|
||||
- `narrative_episode_reintroduction`
|
||||
- `narrative_episode_interrupt_resume`
|
||||
- `narrative_episode_coverage_debt`
|
||||
|
||||
### Exit gate
|
||||
|
||||
- Deterministic episodes cover distinct roles instead of repeating the same development class.
|
||||
- Ordinary unrelated material cannot break productive continuity.
|
||||
- Critical interrupts and return behavior remain correct.
|
||||
- An unavailable payoff closes safely rather than starving world coverage.
|
||||
- Existing combat share and maximum-run gates remain green.
|
||||
|
||||
## Phase 7 — Ensemble balance and long-horizon direction
|
||||
|
||||
### Work
|
||||
|
||||
1. Add bounded screen-time debt for roster MCs and active threads.
|
||||
2. Count meaningful authored exposure, not seconds spent on unavoidable camera holds.
|
||||
3. Prefer an under-covered MC only when they have a presentable new development.
|
||||
4. Prevent prolific event generators—combatants, rulers, or frequently reproducing characters—from
|
||||
monopolizing the narrative through volume alone.
|
||||
5. Reintroduce dormant MCs only after a confirmed change.
|
||||
6. Preserve priority order:
|
||||
critical world event > productive episode > meaningful under-covered thread > bounded world fill.
|
||||
7. Add telemetry for:
|
||||
- authored cuts per MC/thread;
|
||||
- development-family diversity;
|
||||
- repeated semantic action runs;
|
||||
- episode completion/expiry;
|
||||
- dormant reintroduction;
|
||||
- screen-time debt before and after selection.
|
||||
|
||||
### New harness targets
|
||||
|
||||
- `narrative_ensemble_balance`
|
||||
- `narrative_prolific_mc_guard`
|
||||
- `narrative_dormant_reintroduction`
|
||||
|
||||
### Exit gate
|
||||
|
||||
- A prolific MC cannot win repeatedly without distinct developments.
|
||||
- Under-covered MCs receive attention when meaningful footage exists.
|
||||
- No balancing rule forces a weak or unpresentable cut.
|
||||
- Prefer and world-critical behavior remain unchanged.
|
||||
|
||||
## Phase 8 — Cleanup, documentation, and release hardening
|
||||
|
||||
### Delete or retire
|
||||
|
||||
- generic label-plus-relationship Beat enrichment;
|
||||
- latest-similar-development caption lookup when exact ids exist;
|
||||
- direct patch-level dual writes;
|
||||
- router side effects hidden inside memory helpers;
|
||||
- raw-fact Legacy prose fallback;
|
||||
- session-time-based durable event ids;
|
||||
- temporary shadow adapters, migration counters, and harness-only cutover flags.
|
||||
|
||||
### Documentation updates
|
||||
|
||||
- `README.md`
|
||||
- `docs/life-saga.md`
|
||||
- `docs/event-reason.md`
|
||||
- `docs/event-e2e.md`
|
||||
- `docs/story-planner.md`
|
||||
- this plan’s status and completed checkpoints
|
||||
|
||||
Historical plans remain history; their ownership tables must not override the final architecture.
|
||||
|
||||
### Final verification sequence
|
||||
|
||||
1. Clean NML compile.
|
||||
2. New narrative scenario group.
|
||||
3. Existing relationship, caption, Saga, Legacy, scheduler, and persistence scenarios.
|
||||
4. Full `./scripts/harness-run.sh regression`.
|
||||
5. Three organic developed-world soaks:
|
||||
- relationship/lineage-heavy;
|
||||
- conflict/war-heavy;
|
||||
- mixed long-running world.
|
||||
6. One save → quit → restart → reload continuation soak.
|
||||
|
||||
### Release gates
|
||||
|
||||
- Zero camera-health, focus/reason alignment, placeholder, or object-focus failures.
|
||||
- Zero detected semantic duplication in selected Beat/Context/Legacy.
|
||||
- 100% typed presentation coverage for selected authored cuts.
|
||||
- Zero double-ingestion failures.
|
||||
- Zero old-event replay after reload.
|
||||
- Combat remains at or below the existing 35% long-sample gate.
|
||||
- Routine uninterrupted combat run remains at or below three.
|
||||
- Recurring MCs show multiple distinct development families, not only repeated activity.
|
||||
- Episode telemetry demonstrates establishment/development/payoff or an explicit evidence-based
|
||||
expiry reason.
|
||||
- Persistence corruption and unknown-version tests fail safe.
|
||||
|
||||
## Planned source ownership
|
||||
|
||||
| Concern | Final owner |
|
||||
|---|---|
|
||||
| Confirmed observation and idempotency | `NarrativeEventRecorder` |
|
||||
| Immutable world facts | narrative event store |
|
||||
| Character perspective and consequences | narrative projector/store |
|
||||
| Thread state | `NarrativeThreadRules` + `NarrativeStoryStore` |
|
||||
| Candidate semantic presentation | event receipt + registry intake |
|
||||
| Orange Beat/Context English | `NarrativeRenderer` / consolidated `NarrativeProse` |
|
||||
| Relationship evidence | `SagaProse` relation resolver, returning semantic roles |
|
||||
| Episode purpose and coverage | `StoryScheduler` |
|
||||
| Shot eligibility and role choice | `EpisodeShotSelector` |
|
||||
| Legacy chapter construction | `LegacyChapterBuilder` |
|
||||
| Durable save lifecycle | narrative persistence service |
|
||||
| Cast and Legacy layout | existing Saga presentation/UI |
|
||||
| Camera switching and safety | existing director/camera path |
|
||||
|
||||
## Likely file changes
|
||||
|
||||
### Add
|
||||
|
||||
- `IdleSpectator/Narrative/NarrativeEventModels.cs`
|
||||
- `IdleSpectator/Narrative/NarrativeEventRecorder.cs`
|
||||
- `IdleSpectator/Narrative/NarrativePresentation.cs`
|
||||
- `IdleSpectator/Narrative/NarrativeRenderer.cs`
|
||||
- `IdleSpectator/Narrative/LegacyChapterBuilder.cs`
|
||||
- `IdleSpectator/Narrative/NarrativePersistence.cs`
|
||||
- `IdleSpectator/Narrative/NarrativePersistenceModels.cs`
|
||||
- focused partial harness files if `AgentHarness.cs` is split during implementation
|
||||
|
||||
### Major edits
|
||||
|
||||
- `InterestCandidate.cs`
|
||||
- `Events/EventFeedUtil.cs`
|
||||
- relationship, combat, role, war, plot, status, and world-event feeds/patches
|
||||
- `NarrativeModels.cs`
|
||||
- `NarrativeDevelopmentRouter.cs`
|
||||
- `NarrativeStoryStore.cs`
|
||||
- `NarrativeThreadRules.cs`
|
||||
- `NarrativeProse.cs`
|
||||
- `StoryScheduler.cs`
|
||||
- `EpisodeShotSelector.cs`
|
||||
- `CaptionComposer.cs`
|
||||
- `SagaProse.cs`
|
||||
- `LifeSagaMemory.cs`
|
||||
- `LifeSagaPresentation.cs`
|
||||
- `LifeSagaSession.cs`
|
||||
- `AgentHarness.cs`
|
||||
|
||||
File names may be consolidated to match existing repository conventions. The ownership boundaries
|
||||
and exit gates are the contract.
|
||||
|
||||
## Implementation discipline
|
||||
|
||||
1. Land phases in order; do not combine persistence and scheduler behavior in one cutover.
|
||||
2. Keep compatibility adapters internal and temporary.
|
||||
3. Every migrated event family receives deterministic tests before its old path is removed.
|
||||
4. Each phase ends with a clean build and targeted regression.
|
||||
5. Do not tune editorial weights to hide ingestion or prose defects.
|
||||
6. Do not solve semantic duplication with substring-only suppression. String checks may be a final
|
||||
safety net, but enums, roles, event ids, and evidence tags own correctness.
|
||||
7. Do not persist presentation strings when they can be regenerated from semantic state.
|
||||
8. Update this plan with checkpoint evidence as each phase completes.
|
||||
|
||||
## Definition of done
|
||||
|
||||
The plan is finished when a character can:
|
||||
|
||||
1. enter the ensemble through a confirmed development;
|
||||
2. appear in a caption whose Beat states that development once;
|
||||
3. continue through a deliberate multi-beat episode;
|
||||
4. accumulate a concise, non-redundant Legacy;
|
||||
5. survive save, quit, restart, and reload with that history intact; and
|
||||
6. return to the camera only when their story has genuinely changed.
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
IdleSpectator commits to short multi-beat stories instead of channel-surfing ranked tips.
|
||||
|
||||
Feeds, sticky ensembles, presentability, and `InterestDirector` stay in place.
|
||||
`StoryPlanner` sits above the director and owns *which story* the next 30–90s belongs to.
|
||||
`StoryScheduler` owns which durable character thread and episode purpose the next sequence belongs
|
||||
to. `StoryPlanner` retains short-arc aftermath/crisis lifecycle and visual completion.
|
||||
|
||||
## Short-arc board (not the dossier rail)
|
||||
|
||||
|
|
@ -20,7 +21,8 @@ Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to
|
|||
Click toggles Prefer (soft favorite).
|
||||
AFK caption is Identity + Beat + Context via `CaptionComposer` (see [life-saga.md](life-saga.md)).
|
||||
Cast/Legacy depth is rail hover only (`LifeSagaViewController` + `LifeSagaPanel`).
|
||||
Durable observed facts live in `LifeSagaMemory` (independent of ChronicleEnabled).
|
||||
Durable observations live in `NarrativeEventStore`/`NarrativeStoryStore` and are persisted by
|
||||
`NarrativePersistence` (independent of ChronicleEnabled).
|
||||
Hover temporarily pauses camera switching without disabling Idle Spectator.
|
||||
See [life-saga.md](life-saga.md).
|
||||
|
||||
|
|
@ -40,13 +42,19 @@ See also: [life-saga.md](life-saga.md), [scoring-model.md](scoring-model.md), [e
|
|||
## Pipeline
|
||||
|
||||
```text
|
||||
Feeds → InterestRegistry → StoryPlanner (boost / inject)
|
||||
→ InterestVariety.Pick → InterestDirector → CameraDirector
|
||||
Feeds → InterestRegistry → StoryScheduler (thread + episode grammar)
|
||||
→ StoryPlanner (short-arc/crisis policy) → EpisodeShotSelector
|
||||
→ InterestDirector → CameraDirector
|
||||
```
|
||||
|
||||
Director still owns camera, sticky maintain, and switch margins.
|
||||
Planner never calls `MoveCamera`.
|
||||
|
||||
Episodes track Establish, Development, Pressure, TurningPoint, Payoff, Consequence,
|
||||
Reintroduction, and WorldContext roles. A shot must advance an uncovered role or be a permitted
|
||||
critical interrupt; character texture never counts as fake progress. Meaningful-cut exposure debt
|
||||
helps under-covered MCs without forcing weak footage, while repeated subject/action families cool.
|
||||
|
||||
## Arc model
|
||||
|
||||
| Kind | Climax | Hard hold |
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ REGRESSION_SCENARIOS=(
|
|||
discovery
|
||||
world_log
|
||||
camera_presentability
|
||||
narrative_presentation_coverage
|
||||
event_suite
|
||||
chronicle_smoke
|
||||
activity_idle_smoke
|
||||
|
|
|
|||
Loading…
Reference in a new issue