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:
DazedAnon 2026-07-23 14:12:23 -05:00
parent 739cc6492c
commit ff4b40d00e
38 changed files with 5121 additions and 735 deletions

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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");

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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");
});
}

View file

@ -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>

View file

@ -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,

View file

@ -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)

View 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;
}
}

View file

@ -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" : "");
}

View 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 = "";
}

View 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;
}
}
}

View 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;
}
}

View 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;
}
}
}

View file

@ -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

View 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 = "";
}

View 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);
}
}

View 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 ?? "";
}
}

View file

@ -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());
}
}

View file

@ -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++;

View file

@ -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();
}

View file

@ -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;

View file

@ -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))
{

View file

@ -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);
}

View file

@ -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 = "";
}

View file

@ -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);

View file

@ -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)

View file

@ -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.
}
}
}

View file

@ -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

View file

@ -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.

View file

@ -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
```

View file

@ -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 events `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 events 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

View file

@ -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:

View file

@ -0,0 +1,756 @@
# Narrative coherence and durable Legacy plan
## Status
**Implemented — phases 08 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 lifes 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 Beats 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 characters 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 plans 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.

View file

@ -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 3090s 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 |

View file

@ -38,6 +38,7 @@ REGRESSION_SCENARIOS=(
discovery
world_log
camera_presentability
narrative_presentation_coverage
event_suite
chronicle_smoke
activity_idle_smoke