diff --git a/IdleSpectator/ActorChroniclePatches.cs b/IdleSpectator/ActorChroniclePatches.cs index 2a2d0d8..553b150 100644 --- a/IdleSpectator/ActorChroniclePatches.cs +++ b/IdleSpectator/ActorChroniclePatches.cs @@ -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); } } diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index d6f3dee..4879def 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -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(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 + { + 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 + { + 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; diff --git a/IdleSpectator/EventLiveRelationshipHarness.cs b/IdleSpectator/EventLiveRelationshipHarness.cs index 4705175..4a3225e 100644 --- a/IdleSpectator/EventLiveRelationshipHarness.cs +++ b/IdleSpectator/EventLiveRelationshipHarness.cs @@ -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"); diff --git a/IdleSpectator/Events/EventFeedUtil.cs b/IdleSpectator/Events/EventFeedUtil.cs index e2949c5..1b1bc00 100644 --- a/IdleSpectator/Events/EventFeedUtil.cs +++ b/IdleSpectator/Events/EventFeedUtil.cs @@ -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) diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index def12a4..46500b4 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -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, diff --git a/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs b/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs index f934a62..10c4e55 100644 --- a/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs +++ b/IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs @@ -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) diff --git a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs index 6f8815d..fdf7637 100644 --- a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs +++ b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs @@ -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); } /// @@ -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"); }); } diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 968e071..3b60a74 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -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 NarrativeEpisodeGrammar() + { + return new List + { + 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 NarrativeEnsembleBalance() + { + return new List + { + 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 NarrativeProseRedundancy() + { + return new List + { + 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 NarrativePresentationCoverage() + { + return new List + { + 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 NarrativeRelationshipIngestion() + { + return new List + { + 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 NarrativeCanonicalIngestion() + { + return new List + { + 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 NarrativePersistenceRoundTrip() + { + return new List + { + 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 NarrativePersistenceInvalid() + { + return new List + { + 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 NarrativeCameraCutover() { return new List diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index 399c8aa..36535e7 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -57,6 +57,10 @@ public sealed class InterestCandidate public NarrativeFunction NarrativeFunction; public bool HasNarrativeFunction; public string NarrativeFunctionSource = ""; + /// Exact semantic development represented by this candidate, when durable. + public string NarrativeDevelopmentId = ""; + /// Complete typed orange-reason input. Final English is rendered at presentation. + public NarrativePresentationModel NarrativePresentation; /// Live fighters / participants in a cluster (battles, multi-unit events). public int ParticipantCount; /// Sticky multi-actor scoreboard (combat first; war/plot/family later). @@ -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, diff --git a/IdleSpectator/Narrative/EpisodeShotSelector.cs b/IdleSpectator/Narrative/EpisodeShotSelector.cs index b37a574..7bd474c 100644 --- a/IdleSpectator/Narrative/EpisodeShotSelector.cs +++ b/IdleSpectator/Narrative/EpisodeShotSelector.cs @@ -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 = ""; } /// Selects the best presentable shot that can explain its place in the active episode. @@ -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) diff --git a/IdleSpectator/Narrative/LegacyChapterBuilder.cs b/IdleSpectator/Narrative/LegacyChapterBuilder.cs new file mode 100644 index 0000000..13c106d --- /dev/null +++ b/IdleSpectator/Narrative/LegacyChapterBuilder.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// 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. +/// +public static class LegacyChapterBuilder +{ + private const int RetainedChapterLimit = 24; + + public static List Build( + long characterId, + string currentTitle, + LifeSagaFact currentStake, + IReadOnlyCollection currentCast, + int displayLimit = 4) + { + var candidates = new List(RetainedChapterLimit); + var seen = new HashSet(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 into, + HashSet seen) + { + List 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 candidates, + HashSet 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 candidates, + HashSet seen) + { + IReadOnlyList 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 currentCast, + List candidates, + HashSet seen) + { + List 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 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 candidates, int limit) + { + if (limit <= 0) + { + candidates.Clear(); + return; + } + + var selected = new List(limit); + var buckets = new HashSet(); + 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; + } +} diff --git a/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs b/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs index 3582b81..e0f890c 100644 --- a/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs +++ b/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs @@ -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" : ""); } diff --git a/IdleSpectator/Narrative/NarrativeEventModels.cs b/IdleSpectator/Narrative/NarrativeEventModels.cs new file mode 100644 index 0000000..0389f4e --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeEventModels.cs @@ -0,0 +1,56 @@ +namespace IdleSpectator; + +public enum NarrativeEventKind +{ + Unknown, + BondFormed, + BondBroken, + ChildBorn, + FriendFormed, + Kill, + Death, + RoleChanged, + HomeChanged, + Founding, + WarChanged, + PlotChanged +} + +/// One immutable confirmed world observation, shared by all character perspectives. +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 = ""; +} diff --git a/IdleSpectator/Narrative/NarrativeEventRecorder.cs b/IdleSpectator/Narrative/NarrativeEventRecorder.cs new file mode 100644 index 0000000..e44a243 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeEventRecorder.cs @@ -0,0 +1,371 @@ +using System; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Canonical confirmed-observation writer. It owns raw Saga facts and semantic development +/// projection so callers cannot independently emit the same change twice. +/// +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 recordFact, + Func 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; + } + } +} diff --git a/IdleSpectator/Narrative/NarrativeEventStore.cs b/IdleSpectator/Narrative/NarrativeEventStore.cs new file mode 100644 index 0000000..91d25e0 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeEventStore.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// World-session immutable observation store. Persistence is added in Phase 5. +public static class NarrativeEventStore +{ + private static readonly Dictionary Events = + new Dictionary(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 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; + } +} diff --git a/IdleSpectator/Narrative/NarrativeExposureLedger.cs b/IdleSpectator/Narrative/NarrativeExposureLedger.cs new file mode 100644 index 0000000..63d95a7 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeExposureLedger.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Bounded authored-cut ledger. Camera hold seconds do not count; only meaningful selections do. +/// +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 Characters = + new Dictionary(); + private static readonly Dictionary Threads = + new Dictionary(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(Dictionary 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; + } + } +} diff --git a/IdleSpectator/Narrative/NarrativeModels.cs b/IdleSpectator/Narrative/NarrativeModels.cs index 98316e7..8d33516 100644 --- a/IdleSpectator/Narrative/NarrativeModels.cs +++ b/IdleSpectator/Narrative/NarrativeModels.cs @@ -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 CoveredRoles = new List(8); + public readonly List PendingRoles = new List(8); + public readonly List RecentSemanticKeys = new List(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 diff --git a/IdleSpectator/Narrative/NarrativePersistence.cs b/IdleSpectator/Narrative/NarrativePersistence.cs new file mode 100644 index 0000000..7d02d85 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativePersistence.cs @@ -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; + +/// +/// Versioned, fail-safe narrative sidecar. Runtime camera/episode state is intentionally excluded. +/// +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 Preferences = + new Dictionary(); + 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(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(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(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("{ definitely not json"); + } + catch + { + // expected + } + + NarrativeSaveState newer = JsonConvert.DeserializeObject( + "{\"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(List items, int max, Comparison 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 events = + new List(); + [NonSerialized] public List developments = + new List(); + [NonSerialized] public List threads = new List(); + [NonSerialized] public List consequences = + new List(); + [NonSerialized] public List characters = + new List(); + [NonSerialized] public List facts = new List(); + [NonSerialized] public List preferences = + new List(); + + 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( + eventRecords ?? new NarrativeEventRecordDto[0]); + developments = new List( + developmentRecords ?? new NarrativeDevelopmentDto[0]); + threads = new List( + threadRecords ?? new NarrativeThreadDto[0]); + consequences = new List( + consequenceRecords ?? new CharacterConsequenceDto[0]); + characters = new List( + characterRecords ?? new CharacterStoryStateDto[0]); + facts = new List(factRecords ?? new LifeSagaFactDto[0]); + preferences = new List( + 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 = ""; +} diff --git a/IdleSpectator/Narrative/NarrativePersistenceMapper.cs b/IdleSpectator/Narrative/NarrativePersistenceMapper.cs new file mode 100644 index 0000000..1c8bc57 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativePersistenceMapper.cs @@ -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(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 target, string[] source) + { + if (source != null) target.AddRange(source); + } +} diff --git a/IdleSpectator/Narrative/NarrativePresentation.cs b/IdleSpectator/Narrative/NarrativePresentation.cs new file mode 100644 index 0000000..21dfb44 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativePresentation.cs @@ -0,0 +1,754 @@ +using System; +using System.Text; + +namespace IdleSpectator; + +/// +/// Semantic presentation kind. These values describe observed meaning, never final player-facing +/// English. +/// +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 +} + +/// Complete semantic input for one orange Beat. Contains no final sentence. +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 = ""; + /// Catalog-authored observation clause used only by the safe typed fallback. + 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 + }; + } + + /// + /// Typed presentation for relationship-bearing happiness signals. Ambient effects deliberately + /// stay on the compatibility path until their semantic catalog migration. + /// + 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 + }; + } + + /// + /// Safe typed envelope for catalog/ensemble events that do not create a durable character + /// development. The renderer owns subject removal and final HUD hygiene. + /// + 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; + } +} + +/// Single English owner for typed narrative presentation. +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 ""; + } + } + + /// + /// Safety audit only. Semantic models prevent duplication; this detects regressions in rendered + /// output and compatibility fallbacks without rewriting their prose. + /// + 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(); + } +} + +/// Bounded counters for selected-caption prose provenance and quality. +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 ?? ""; + } +} diff --git a/IdleSpectator/Narrative/NarrativeProse.cs b/IdleSpectator/Narrative/NarrativeProse.cs index fc00864..177696d 100644 --- a/IdleSpectator/Narrative/NarrativeProse.cs +++ b/IdleSpectator/Narrative/NarrativeProse.cs @@ -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()); - } } diff --git a/IdleSpectator/Narrative/NarrativeStoryStore.cs b/IdleSpectator/Narrative/NarrativeStoryStore.cs index 7ff2b05..b655378 100644 --- a/IdleSpectator/Narrative/NarrativeStoryStore.cs +++ b/IdleSpectator/Narrative/NarrativeStoryStore.cs @@ -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 AllDevelopments() => Developments.Values; + internal static IEnumerable AllThreads() => Threads.Values; + internal static IEnumerable AllConsequences() => Consequences.Values; + internal static IEnumerable 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++; diff --git a/IdleSpectator/Narrative/NarrativeTelemetry.cs b/IdleSpectator/Narrative/NarrativeTelemetry.cs index 681cdc7..0390407 100644 --- a/IdleSpectator/Narrative/NarrativeTelemetry.cs +++ b/IdleSpectator/Narrative/NarrativeTelemetry.cs @@ -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(); } diff --git a/IdleSpectator/Narrative/StoryScheduler.cs b/IdleSpectator/Narrative/StoryScheduler.cs index 4165fd0..e65f8b8 100644 --- a/IdleSpectator/Narrative/StoryScheduler.cs +++ b/IdleSpectator/Narrative/StoryScheduler.cs @@ -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 { 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 { 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 { 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; diff --git a/IdleSpectator/Story/CaptionComposer.cs b/IdleSpectator/Story/CaptionComposer.cs index 3f41524..ff79049 100644 --- a/IdleSpectator/Story/CaptionComposer.cs +++ b/IdleSpectator/Story/CaptionComposer.cs @@ -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)) { diff --git a/IdleSpectator/Story/LifeSagaMemory.cs b/IdleSpectator/Story/LifeSagaMemory.cs index f29c637..a43aaa3 100644 --- a/IdleSpectator/Story/LifeSagaMemory.cs +++ b/IdleSpectator/Story/LifeSagaMemory.cs @@ -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(); } + internal static IEnumerable 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); - } - } } /// @@ -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); } /// @@ -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); } /// @@ -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); } diff --git a/IdleSpectator/Story/LifeSagaPresentation.cs b/IdleSpectator/Story/LifeSagaPresentation.cs index 29c79fd..6241e99 100644 --- a/IdleSpectator/Story/LifeSagaPresentation.cs +++ b/IdleSpectator/Story/LifeSagaPresentation.cs @@ -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(); 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 facts = LifeSagaMemory.SelectLegacy(unitId, 8); - var seenLines = new HashSet(StringComparer.OrdinalIgnoreCase); - List 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 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; - } - } - - /// - /// 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. - /// - private static bool CastOwnsLivingRelation(LifeSagaFact fact, HashSet 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 = ""; } diff --git a/IdleSpectator/Story/LifeSagaRoster.cs b/IdleSpectator/Story/LifeSagaRoster.cs index 3210ad5..e6b3efb 100644 --- a/IdleSpectator/Story/LifeSagaRoster.cs +++ b/IdleSpectator/Story/LifeSagaRoster.cs @@ -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); diff --git a/IdleSpectator/Story/LifeSagaSession.cs b/IdleSpectator/Story/LifeSagaSession.cs index 8e7f4bb..567c960 100644 --- a/IdleSpectator/Story/LifeSagaSession.cs +++ b/IdleSpectator/Story/LifeSagaSession.cs @@ -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(); + } + + /// Stable save identity: excludes object hashes and load counters. + 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) diff --git a/IdleSpectator/Story/LifeSagaSessionPatches.cs b/IdleSpectator/Story/LifeSagaSessionPatches.cs index deb8f38..76a5ecc 100644 --- a/IdleSpectator/Story/LifeSagaSessionPatches.cs +++ b/IdleSpectator/Story/LifeSagaSessionPatches.cs @@ -1,3 +1,5 @@ +using System; +using System.Reflection; using HarmonyLib; namespace IdleSpectator; @@ -34,3 +36,36 @@ public static class LifeSagaSessionPatches } } } + +/// Promote narrative recovery state only after WorldBox confirms a save completed. +[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. + } + } +} diff --git a/IdleSpectator/Story/SagaProse.cs b/IdleSpectator/Story/SagaProse.cs index a1132c6..d461f9f 100644 --- a/IdleSpectator/Story/SagaProse.cs +++ b/IdleSpectator/Story/SagaProse.cs @@ -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; - } - /// Identity title for MCs. Empty when no real role (caller falls back to species/job). public static string Title(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory) { @@ -362,124 +340,6 @@ public static class SagaProse } } - /// - /// Beat tip suffix without leading " · ". Empty when redundant or unknown. - /// tipVerbClass: "combat" | "love" | "grief" | "plot" | "other" - /// - 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 diff --git a/README.md b/README.md index 8e935cc..6bdd5fd 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,14 @@ events, and returns only when the story has new evidence. `EventStrength` still visual value, but it no longer owns continuity. Bounded character/world fill runs when no episode has a usable development. -While watching, a **character beat caption** shows who they are (Identity), an orange **event sentence** (Beat), and muted Context (story spine or open stake). -Main characters get `Name · Title` when they have a real role; tips may gain a self-explanatory relation clause. +While watching, a **character beat caption** shows who they are (Identity), an orange **event +sentence** (Beat), and muted Context (story spine or confirmed pressure). Main characters get +`Name · Title` when they have a real role. The Beat is rendered once from typed event evidence, so +relationship details are not appended a second time. + +Confirmed MC events, threads, consequences, Legacy, and Prefer choices are stored in a versioned +per-world sidecar at the game save boundary. Runtime camera state and episode timing remain +session-only. See [`docs/life-saga.md`](docs/life-saga.md) and [`docs/event-reason.md`](docs/event-reason.md). Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps the camera. diff --git a/docs/event-e2e.md b/docs/event-e2e.md index a747bdf..dfd5e78 100644 --- a/docs/event-e2e.md +++ b/docs/event-e2e.md @@ -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 ``` diff --git a/docs/event-reason.md b/docs/event-reason.md index e60286d..36fdce9 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -16,8 +16,10 @@ chooses episode developments before bounded ambient/world fallback. Neither auth Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only). -Orange dossier reason = the owning A event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`). -`CaptionComposer` may append a presentation-only relation clause (` · their 3rd clash`) and show a separate Identity line (`Name · King of X`). +Orange dossier reason = the owning A event’s semantic `NarrativePresentationModel` while the scene +is active (`InterestDirector.TryGetOwnedReasonCandidate`). `Label` remains the presentability and +compatibility input, but `NarrativeRenderer` owns the final Beat. `CaptionComposer` never appends a +generic relationship suffix. Identity remains a separate line (`Name · King of X`). Identity titles and stake slogans never enter `InterestCandidate.Label`; `identity_filter` / `IsIdentityWhy` remain selection safety gates. The focused unit must be a tip principal: @@ -108,11 +110,16 @@ Roadmap + shared contract: [`sticky-ensemble-plan.md`](sticky-ensemble-plan.md). Shared pieces: `LiveEnsemble` builders, `StickyScoreboard`, `EventReason.Ensemble`. -## EventReason +## Semantic presentation -[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates. -It lives in the Events module with catalogs (not in the director). -Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply(template, a, b, id)`. +[`IdleSpectator/Narrative/NarrativePresentation.cs`](../IdleSpectator/Narrative/NarrativePresentation.cs) +defines the typed presentation contract and is the final orange-Beat voice owner. Confirmed durable +events receive their exact event/development presentation from `NarrativeEventRecorder`. Ambient, +ensemble, status, and world candidates receive a typed envelope at `EventFeedUtil.Register`. + +[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) remains a catalog +template/compatibility facade. Feeds may use its helpers to provide authored semantic input, but +downstream code must not parse that English to discover actors, relationships, or developments. ## Events module layout @@ -121,7 +128,9 @@ Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply | `Events/EventCatalog.cs` (+ `Catalogs/EventCatalog.*.cs`) | **One inventory** - nested domains + `IsCameraWorthy` | | `Events/Feeds/` | Build + register **A** candidates from catalog dials | | `Events/Patches/` | Harmony hooks → feeds / Activity | -| `Events/EventReason.cs` | Orange reason sentences | +| `Narrative/NarrativePresentation.cs` | Typed presentation + final orange Beat rendering | +| `Narrative/NarrativeEventRecorder.cs` | Stable confirmed event/development receipt | +| `Events/EventReason.cs` | Catalog-authored presentation input / compatibility labels | | `Events/LiveEnsemble.cs` | Multi-actor snapshot + combat builders | | `Events/LiveSceneStickyState.cs` | Durable sticky scoreboard on a candidate | | `Events/StickyScoreboard.cs` | Lock / enroll / refresh / promote / scale-hold | @@ -159,10 +168,13 @@ See `scoring-model.json`. ## Dossier display -`UnitDossier.EventLabelBeat` keeps EventReason / `Name verb …` sentences. +`UnitDossier.EventLabelBeat` keeps the owned reason presentable; `CaptionComposer` prefers the +candidate's typed presentation and exact development id. It rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`). Active EventLed A scenes should not silently blank a valid Label. `EventPresentability.WouldShow` is the shared gate so unpresentable Labels never win Watching. +`narrative_presentation_coverage` is the registry/rendering gate: structured intake, no +compatibility lookup, and no semantic redundancy. ## See also diff --git a/docs/life-saga.md b/docs/life-saga.md index 543ee4d..0867292 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -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: diff --git a/docs/narrative-coherence-and-persistence-plan.md b/docs/narrative-coherence-and-persistence-plan.md new file mode 100644 index 0000000..334875f --- /dev/null +++ b/docs/narrative-coherence-and-persistence-plan.md @@ -0,0 +1,756 @@ +# Narrative coherence and durable Legacy plan + +## Status + +**Implemented — phases 0–8 complete (2026-07-23).** + +Release evidence: clean NML restart; 20/20 full regression scenarios; semantic presentation +coverage at `intake=1/0`, `compat=0`, `redundant=0`; 10/10 canonical event-family idempotency +matrix; persistence round-trip and corrupt/newer-schema recovery; Legacy lineage/combat +consolidation; episode grammar and ensemble balance; relationship live suite 3/3; seeded and +20-second natural story soaks. The persistence format and real save-complete/load-complete hooks +are integrated; automated verification intentionally uses isolated DTO round-trips rather than +overwriting a player's WorldBox save. + +This is the successor to +[`mc-story-director-rewrite-plan.md`](mc-story-director-rewrite-plan.md). The story-first +director and current Saga UI are the foundation; this plan does not reopen that cutover or redesign +the HUD. + +The work is complete only when: + +- every selected orange reason is rendered once from semantic evidence; +- each observed game event enters narrative state through one idempotent writer; +- captions, threads, Cast, and Legacy refer to the same event identity; +- durable MC stories survive a real save/reload cycle; +- episodes communicate a deliberate development rather than only selecting the highest related + score; and +- the old label-concatenation, duplicate-routing, and raw-fact Legacy fallbacks are removed. + +## Product outcome + +IdleSpectator should feel like an automatically edited chronicle of a small ensemble. + +For every authored cut, the player should be able to understand: + +1. **Who is this about?** +2. **What changed?** +3. **Who or what was involved?** +4. **Why is this development part of the current story?** +5. **What lasting consequence belongs in this life’s Legacy?** + +The same confirmed observation must answer all five questions. Camera captions and Legacy may use +different tense and detail, but they must not invent separate versions of the event. + +## Problems this plan resolves + +### Multiple prose owners + +An `InterestCandidate.Label` may already say `Gains a child, Omyahel`, after which +`CaptionComposer.EnrichBeat` asks `SagaProse.BeatClause` to add `their child`. Both pieces are +individually reasonable, but together they repeat the same relationship. + +The structured `NarrativeProse` path avoids this for a small set of event ids. Most event families +still depend on authored labels, feed-local string construction, or generic relationship suffixes. + +### Multiple fact writers + +Several Harmony patches call both `LifeSagaMemory.Record...` and +`NarrativeDevelopmentRouter...`, while the memory helper also calls the router. Store idempotency +limits visible damage, but ownership is ambiguous and the system attempts to emit the same semantic +change more than once. + +### Session-only stories + +`LifeSagaSession.ResetForWorldChange` currently clears Saga memory and the narrative store after a +map load. This is safe, but it makes “Legacy” a session summary rather than a durable world history. + +### Related-shot selection is not yet episode composition + +`StoryScheduler` owns story-first selection and tracks episode purpose, but +`EpisodeShotSelector` still chooses mainly through one editorial score. It does not yet require the +episode to cover distinct editorial roles such as establishment, change, pressure, and consequence. + +### Legacy has overlapping sources + +Legacy currently combines narrative chapters, selected `LifeSagaFact` records, special combat +summaries, and presentation-layer suppression rules. It works, but its correctness depends on +several layers agreeing about duplicates. + +## Non-goals + +- No new tabs, dossier layout, or rail redesign. +- No return to event-first camera ownership. +- No invented emotions, motives, causal claims, victories, heirs, or rivalries. +- No forced establishing shots when presentable footage does not exist. +- No persistence of live `Actor` references, camera position, current combat phase, or active + episode timing. +- No permanent compatibility setting for the old prose or old narrative writer. +- No attempt to turn every ambient activity into a durable life event. + +## Target architecture + +```text +Confirmed game observation + | + v +NarrativeEventRecorder -- stable id + idempotency + provenance + | + +--> immutable NarrativeEventRecord (one world fact) + | + +--> character-perspective NarrativeDevelopment projections + | +--> threads / consequences / story potential + | +--> durable per-life indexes + | + +--> NarrativeEventReceipt + +--> InterestCandidate semantic presentation + exact development id + +InterestCandidate --> StoryScheduler --> camera cut + --> NarrativeRenderer --> orange Beat / Context + +NarrativeEventRecord + threads + consequences + --> LegacyChapterBuilder --> Legacy + +Durable records / identities / threads / preferences + <--> versioned world narrative save +``` + +### One observation, multiple perspectives + +A world event is recorded once. It may produce more than one character-perspective development. + +Example: a birth creates one `NarrativeEventRecord`, then: + +- the parent projection can render `Welcomes first child Omyahel`; +- the child projection can render `Is born to Joon`; and +- a partner projection may be created only when a confirmed parent/partner relationship supports it. + +These projections share the same event id. They are not mirrored copies pretending to be separate +world events. + +## Core contracts + +### 1. One fact, one writer + +Every confirmed narrative observation enters through `NarrativeEventRecorder.Record`. Callers do +not separately write Saga memory, narrative developments, consequences, or thread state. + +### 2. One selected event, one presentation model + +Every selected candidate carries an exact semantic presentation reference. Caption composition +must not search for “the latest similar event” and must not infer the related actor from prose. + +### 3. One semantic fact, one phrase + +The orange Beat is rendered as a complete sentence by one renderer. Relationship information is +included only when it contributes a fact not already expressed by the action. + +### 4. Context adds information + +Context may name confirmed pressure, an unresolved question, or a consequence. It must not +paraphrase the Beat, repeat Identity, or expose scheduler terminology. + +### 5. Runtime and durable time are different + +- World date/tick orders persistent facts and supplies display dates. +- Session monotonic time controls cooldowns, holds, momentum decay, and active episode timing. +- Session timestamps are never serialized as durable chronology. + +### 6. Persistence is fail-safe + +Missing, corrupt, or newer-version narrative data must not prevent a world from loading. The mod +logs the problem, preserves the unreadable file when possible, starts an empty narrative state, and +continues operating. + +### 7. Episode grammar never fabricates footage + +The scheduler may wait, hand off, or close an episode when a needed role has no presentable +candidate. It may not manufacture a development just to complete a planned sequence. + +## Proposed data model + +### `NarrativeEventRecord` + +Immutable world observation: + +- schema version; +- stable event id; +- semantic event kind; +- subject, other, and participant identity snapshots; +- relationship/evidence roles rather than player-facing relation text; +- correlation, family, city, kingdom, war, and plot keys; +- previous state, new state, and confirmed outcome; +- world tick/date; +- evidence source and confidence; +- magnitude and presentability; +- ingestion provenance and dedupe key. + +### `NarrativeDevelopment` + +Character-perspective projection: + +- event id; +- perspective character id; +- development kind and narrative function; +- other character/participants in that perspective; +- thread id when assigned; +- confirmed change/outcome; +- session-observed time for runtime scheduling; +- world tick/date for durable ordering. + +Existing `NarrativeDevelopment` fields should be migrated rather than duplicated where possible. +`SubjectId` becomes the perspective owner explicitly instead of relying on an implied viewpoint. + +### `NarrativePresentationModel` + +Player-facing semantic input, containing no final English: + +- event/development id; +- presentation kind and tense; +- perspective id; +- primary and secondary identity snapshots; +- evidence-backed relationship role; +- ordinal/count when confirmed; +- change and outcome tokens; +- ensemble side snapshots when applicable; +- confidence and provenance. + +The model is attached to or referenced by `InterestCandidate`. Dynamic ensemble cards may retain a +typed ensemble presentation rather than becoming durable character developments. + +### `NarrativeEventReceipt` + +Returned by the recorder: + +- whether this was a new event or a duplicate observation; +- stable event id; +- development ids by perspective; +- semantic presentation model for the emitting candidate; +- any rejection or confidence reason. + +### Persistence DTOs + +Use separate plain DTOs rather than serializing runtime dictionaries or Unity/game objects. + +Persist: + +- durable event records within retention limits; +- identity snapshots; +- character story indexes; +- open/resolved threads and consequences; +- authored Legacy chapter state if it cannot be deterministically rebuilt; +- Saga Prefer choices; +- schema version, stable world key, and last durable world tick. + +Rebuild after load: + +- dictionaries and reverse indexes; +- effective story potential and runtime decay anchors; +- live actor bindings; +- current Saga ranking; +- captions, active episode, cooldowns, and camera state. + +## Implementation phases + +| Phase | Primary outcome | Risk | +|---|---|---| +| 0 | Measurable baseline and explainable cuts | Low | +| 1 | Relationship/lineage captions rendered once | Medium | +| 2 | One canonical, idempotent narrative writer | High | +| 3 | Complete typed orange-reason coverage | High | +| 4 | One semantic Legacy chapter pipeline | Medium | +| 5 | Save/reload durability and recovery | Very high | +| 6 | Deliberate episode grammar | High | +| 7 | Long-horizon ensemble balance | Medium | +| 8 | Old-path deletion and release proof | Medium | + +The order is intentional. The visible caption defect is fixed first, canonical ingestion then +removes its root ownership ambiguity, and persistence begins only after the in-memory facts and +chapters have one stable shape. Scheduler behavior changes after persistence so camera tuning +cannot hide state migration defects. + +## Phase 0 — Baseline, inventory, and observability + +### Work + +1. Capture the current full regression result and a natural story-first soak as the comparison + baseline. +2. Add a machine-readable inventory of candidate families and current prose provenance: + structured narrative, `EventReason`, feed-authored label, ensemble renderer, or compatibility + fallback. +3. Add selection telemetry for: + - candidate key and asset id; + - event/development id, when present; + - prose path; + - perspective and related ids; + - thread and episode purpose; + - whether Beat, Context, or relation semantics overlap. +4. Add a deterministic reproduction of: + `Gains a child, Omyahel · their child`. +5. Add a harness snapshot that explains one selected cut from observation through rendered caption. + +### New harness targets + +- `narrative_prose_redundancy` +- `narrative_cut_explain` +- `narrative_prose_coverage` + +### Exit gate + +- Existing regression remains green. +- The child redundancy test fails for the expected semantic reason before the fix. +- Every naturally selected authored cut reports its prose provenance. + +## Phase 1 — Semantic presentation foundation and relationship cutover + +This phase fixes the reported orange-reason problem first while establishing the model used by the +remaining migrations. + +### Work + +1. Add `NarrativePresentationModel`, explicit viewpoint, and a renderer that owns the complete Beat. +2. Stamp an exact event/development reference onto `InterestCandidate`; stop using + `NarrativeStoryStore.LatestFor(..., 8f)` when an exact id exists. +3. Implement semantic rendering for: + - lover formed/broken; + - friend formed; + - parent/child and birth; + - death and grief with confirmed relationship; + - gifts or relationship interactions that name both actors. +4. Encode relationship roles as data. Do not derive them from already-rendered text. +5. Add semantic novelty checks: + - a relation descriptor is omitted when the action already states it; + - ordinal detail such as `first child` is allowed because it adds information; + - names appear once within a Beat; + - Context cannot repeat the Beat’s event, relationship, role, or outcome tags. +6. Structured Beats bypass `CaptionComposer.EnrichBeat`. +7. Keep the legacy label path only for unmigrated event families during this phase. + +### Required prose matrix + +Test each applicable event with: + +- named and unnamed related actor; +- living and dead related actor; +- MC as subject and MC as related actor; +- parent and child viewpoints; +- first, second, and 3+ child; +- current and former lover; +- best friend and ordinary friend; +- missing/stale actor binding with a valid identity snapshot; +- status-banner ownership; +- re-entry caption; +- Beat plus thread Context. + +### Exit gate + +- No relationship or lineage Beat repeats its action, relationship, or related name. +- All relationship candidates bind to the exact development they display. +- No viewpoint reversal in the deterministic matrix. +- Existing caption, relationship, Legacy, and presentability scenarios pass. + +## Phase 2 — Canonical event ingestion and single-writer cutover + +### Work + +1. Introduce `NarrativeEventRecorder` and `NarrativeEventReceipt`. +2. Define stable dedupe keys from semantic identity and durable world chronology. Use a + source-instance/correlation token when the game exposes one; otherwise use a bounded + family-specific dedupe window so two legitimate same-kind events are not collapsed. Do not use + final prose or session-only `Time.unscaledTime` as persistent identity. +3. Make the recorder transaction: + - validate evidence; + - record or identify the event; + - create perspective developments; + - apply thread rules; + - create consequences; + - update per-life indexes; + - return candidate presentation metadata. +4. Convert `LifeSagaMemory` into a read/index/projection facade. Its public `Record...` helpers + either delegate once to the recorder during migration or are removed. +5. Remove direct `NarrativeDevelopmentRouter...` calls from patches that already record the same + observation. +6. Migrate ingestion by family: + +| Order | Family | Representative sources | +|---|---|---| +| 1 | Relationship and lineage | `ActorChroniclePatches`, `RelationshipEventPatches`, relationship feed | +| 2 | Death, kill, rivalry, combat outcome | actor patches, combat/story hooks | +| 3 | Role, founding, home gain/loss | roster deltas, city/kingdom hooks | +| 4 | War and plot | war/plot feeds and lifecycle patches | +| 5 | Transformation and recovery | status/happiness routers | +| 6 | World-context events | disasters, eras, discoveries, civic changes | + +7. Preserve observation-before-confirmation rules: a behavior return value alone is not semantic + success. +8. Add bounded duplicate-observation telemetry by event family and provenance. + +### New harness targets + +- `narrative_ingestion_idempotent` +- `narrative_ingestion_perspectives` +- `narrative_hook_single_writer` +- `narrative_event_candidate_alignment` + +### Exit gate + +- One game observation produces one event record regardless of how many patched surfaces observe it. +- Expected character projections are created once. +- No production patch calls both memory recording and the development router. +- Candidate, caption, consequence, and thread all report the same event id. +- Story potential and Legacy counts do not inflate under duplicate hook invocation. + +## Phase 3 — Complete semantic orange-reason migration + +### Work + +Migrate all remaining selected candidate families to typed presentation: + +| Family | Rendering rule | +|---|---| +| Role and succession | Describe the confirmed role transition; do not repeat Identity | +| Founding and home | Name the confirmed home/settlement when known | +| Combat and rivalry | Separate live action, decisive outcome, and earned rivalry | +| War and plot | Use confirmed sides/phase/outcome; avoid invented victory or betrayal | +| Disaster and loss | State the observed impact, not an assumed emotion or cause | +| Status and transformation | Distinguish application, ongoing condition, recovery, and completion | +| Family/war/plot/combat ensembles | Keep typed side/count presentation owned by the ensemble renderer | +| Milestones and discoveries | State the concrete first/change | +| Ambient texture | Use concise authored semantic templates; do not create durable consequences | +| Meta/world context | Preserve useful world-scale subjects without pretending they are MC developments | + +Additional work: + +1. Make `EventReason` a compatibility facade over typed rendering where it remains useful; prevent + feeds from constructing selected prose ad hoc. +2. Replace string-based verb/relation classification with presentation enums/tags. +3. Remove generic `SagaProse.BeatClause` concatenation from caption composition. Relation evidence + remains useful as semantic input to the renderer. +4. Add a safe unknown-event presentation that never appends inferred relationship prose. +5. Audit capitalization, punctuation, tense, names, raw ids, placeholder tokens, and missing actor + behavior across the entire catalog. +6. Track structured coverage at registry intake and at actual selection. + +### Exit gate + +- 100% of selected authored candidates use a typed renderer or a typed ensemble renderer. +- No selected candidate uses a feed-local final sentence. +- No raw asset id, enum name, placeholder, or duplicated semantic tag reaches the HUD. +- The generic label-plus-relation-suffix path is deleted. +- Catalog prose audit and full presentability suite pass. + +## Phase 4 — Unified Legacy chapter authoring + +### Work + +1. Introduce `LegacyChapterBuilder` as the only Legacy author. +2. Build chapters from semantic events, consequences, and resolved/meaningfully changed threads. +3. Replace presentation-time competition between: + - narrative chapters; + - raw `LifeSagaFact` lines; + - roster-stamped prose chapters; and + - special combat prose. +4. Keep thread-specific merge policy: + - partnership formation and loss remain distinct identity changes; + - 3+ births consolidate into a lineage chapter; + - repeated kills consolidate into one combat chapter; + - an earned rivalry remains distinct from a kill count; + - role gain/loss may form one reign chapter when both are known; + - war/plot chapters prefer confirmed outcome over entry notification; + - living Cast relationships do not echo in Legacy unless a historical transition matters. +5. Rank chapters by consequence importance, narrative completeness, recency, and diversity—not raw + event volume. +6. Preserve more semantic chapters than the two-line panel can display; presentation chooses the + best two without destroying history. +7. Ensure a deceased or currently unbound character’s Legacy renders entirely from snapshots. + +### New harness targets + +- `narrative_legacy_chapters` +- `narrative_legacy_lineage` +- `narrative_legacy_role_arc` +- `narrative_legacy_war_outcome` +- `narrative_legacy_snapshot_only` + +### Exit gate + +- Each displayed Legacy line traces to one chapter id and its source event/thread ids. +- Cast, current Context, and Legacy do not repeat the same unchanged fact. +- Existing combat consolidation remains correct. +- Raw-fact prose fallback is removed from production Legacy rendering. + +## Phase 5 — Durable narrative persistence + +### Phase 5A: integration spike + +Before writing the format: + +1. Identify the most reliable WorldBox/NML save-start, save-complete, and load-complete hooks. +2. Determine a stable world/save identity that survives process restart. The current runtime bind + key contains object identity and is unsuitable for durable storage. +3. Confirm the supported mod-data location and atomic replace behavior on the target platforms. +4. Verify actor ids across a real save/reload and document cases where ids are recycled or absent. + +Default implementation: a versioned sidecar in the supported mod-data location, keyed by stable +save/world identity. If the game exposes a reliable per-save mod-data container, prefer that after +the spike. + +### Phase 5B: format and lifecycle + +1. Add a versioned `NarrativeSaveState` DTO and explicit mapper. +2. Serialize only durable fields and world chronology. +3. Write atomically through temporary file plus replace; keep one last-known-good backup. +4. Tag each snapshot with its confirmed save generation/fingerprint and durable world tick. +5. Debounce dirty writes, but promote a snapshot to durable state only at a confirmed world-save + boundary. A transition or clean-shutdown flush may preserve a recovery snapshot, but it must not + silently become the history of an older game save. +6. On load, select the snapshot matching the loaded save generation. Never apply narrative events + newer than the loaded world tick; preserve unmatched newer data as a separate recovery + generation. +7. On load: + - clear runtime state; + - read and validate the matching world state; + - rehydrate indexes; + - rebind living actors; + - retain missing/dead actors as snapshot-only history; + - rebase runtime freshness from durable world time; + - resolve or abandon threads whose evidence no longer exists; + - rebuild roster ranking and clear the active episode. +8. Add retention limits by character/event importance and test maximum expected file size. Events + referenced by retained threads, consequences, or Legacy chapters cannot be pruned; compact them + into a durable semantic chapter record before their detailed event payload is removed. +9. Add schema migration support beginning with version 1. Unknown newer schemas load empty without + overwriting the unreadable data. +10. Persist Saga Prefer by stable character/world identity. Do not persist transient rail order. + +### New harness and live targets + +- `narrative_persistence_roundtrip` +- `narrative_persistence_corrupt` +- `narrative_persistence_unknown_version` +- `narrative_persistence_missing_actor` +- `narrative_persistence_no_replay` +- a real save, process restart, and reload check + +### Exit gate + +- Relationships, births, deaths, threads, consequences, Legacy, and Prefer survive a real restart. +- Reload does not duplicate events or replay old changes as new camera developments. +- A corrupt or mismatched sidecar cannot block game load. +- Snapshot-only Legacy remains readable when an actor cannot be rebound. +- Persistence adds no recurring full-world scan and no visible save hitch. + +## Phase 6 — Editorial episode grammar + +### Data additions + +Add: + +- `EpisodeShotRole`: Establish, Development, Pressure, TurningPoint, Payoff, Consequence, + Reintroduction, WorldContext; +- covered/pending role ledger; +- recent semantic event/action keys; +- non-progress and repeated-subject counters; +- explicit episode completion reason; +- per-thread coverage debt and last-shown world time. + +### Work + +1. Assign shot roles from semantic development/function and current episode state. +2. Define a minimal grammar by `EpisodePurpose`: + +| Purpose | Desired communication | +|---|---| +| Establish | protagonist/cast plus the confirmed opening change | +| Develop | new pressure or state change | +| Payoff | turning point or confirmed outcome | +| Consequence | what the outcome changed for the protagonist | +| Reintroduce | identity/context plus a genuinely new development | + +3. Apply eligibility constraints before scalar score: + - the shot must advance an uncovered role or be a permitted interrupt; + - identical semantic action/subject combinations cool down; + - texture cannot satisfy episode progress; + - routine combat remains within its existing budget; + - critical world events can interrupt and the episode may later resume. +4. Use thread coverage debt to favor stories that have developed off-camera. +5. End or hand off when: + - the information goal is met and a settle beat has elapsed; + - no presentable development arrives before expiry; + - the thread resolves or becomes invalid; + - a stronger coherent episode has materially greater coverage debt. +6. Log why each selected shot satisfied the episode and which role remains uncovered. + +### New harness targets + +- `narrative_episode_grammar` +- `narrative_episode_no_forced_footage` +- `narrative_episode_reintroduction` +- `narrative_episode_interrupt_resume` +- `narrative_episode_coverage_debt` + +### Exit gate + +- Deterministic episodes cover distinct roles instead of repeating the same development class. +- Ordinary unrelated material cannot break productive continuity. +- Critical interrupts and return behavior remain correct. +- An unavailable payoff closes safely rather than starving world coverage. +- Existing combat share and maximum-run gates remain green. + +## Phase 7 — Ensemble balance and long-horizon direction + +### Work + +1. Add bounded screen-time debt for roster MCs and active threads. +2. Count meaningful authored exposure, not seconds spent on unavoidable camera holds. +3. Prefer an under-covered MC only when they have a presentable new development. +4. Prevent prolific event generators—combatants, rulers, or frequently reproducing characters—from + monopolizing the narrative through volume alone. +5. Reintroduce dormant MCs only after a confirmed change. +6. Preserve priority order: + critical world event > productive episode > meaningful under-covered thread > bounded world fill. +7. Add telemetry for: + - authored cuts per MC/thread; + - development-family diversity; + - repeated semantic action runs; + - episode completion/expiry; + - dormant reintroduction; + - screen-time debt before and after selection. + +### New harness targets + +- `narrative_ensemble_balance` +- `narrative_prolific_mc_guard` +- `narrative_dormant_reintroduction` + +### Exit gate + +- A prolific MC cannot win repeatedly without distinct developments. +- Under-covered MCs receive attention when meaningful footage exists. +- No balancing rule forces a weak or unpresentable cut. +- Prefer and world-critical behavior remain unchanged. + +## Phase 8 — Cleanup, documentation, and release hardening + +### Delete or retire + +- generic label-plus-relationship Beat enrichment; +- latest-similar-development caption lookup when exact ids exist; +- direct patch-level dual writes; +- router side effects hidden inside memory helpers; +- raw-fact Legacy prose fallback; +- session-time-based durable event ids; +- temporary shadow adapters, migration counters, and harness-only cutover flags. + +### Documentation updates + +- `README.md` +- `docs/life-saga.md` +- `docs/event-reason.md` +- `docs/event-e2e.md` +- `docs/story-planner.md` +- this plan’s status and completed checkpoints + +Historical plans remain history; their ownership tables must not override the final architecture. + +### Final verification sequence + +1. Clean NML compile. +2. New narrative scenario group. +3. Existing relationship, caption, Saga, Legacy, scheduler, and persistence scenarios. +4. Full `./scripts/harness-run.sh regression`. +5. Three organic developed-world soaks: + - relationship/lineage-heavy; + - conflict/war-heavy; + - mixed long-running world. +6. One save → quit → restart → reload continuation soak. + +### Release gates + +- Zero camera-health, focus/reason alignment, placeholder, or object-focus failures. +- Zero detected semantic duplication in selected Beat/Context/Legacy. +- 100% typed presentation coverage for selected authored cuts. +- Zero double-ingestion failures. +- Zero old-event replay after reload. +- Combat remains at or below the existing 35% long-sample gate. +- Routine uninterrupted combat run remains at or below three. +- Recurring MCs show multiple distinct development families, not only repeated activity. +- Episode telemetry demonstrates establishment/development/payoff or an explicit evidence-based + expiry reason. +- Persistence corruption and unknown-version tests fail safe. + +## Planned source ownership + +| Concern | Final owner | +|---|---| +| Confirmed observation and idempotency | `NarrativeEventRecorder` | +| Immutable world facts | narrative event store | +| Character perspective and consequences | narrative projector/store | +| Thread state | `NarrativeThreadRules` + `NarrativeStoryStore` | +| Candidate semantic presentation | event receipt + registry intake | +| Orange Beat/Context English | `NarrativeRenderer` / consolidated `NarrativeProse` | +| Relationship evidence | `SagaProse` relation resolver, returning semantic roles | +| Episode purpose and coverage | `StoryScheduler` | +| Shot eligibility and role choice | `EpisodeShotSelector` | +| Legacy chapter construction | `LegacyChapterBuilder` | +| Durable save lifecycle | narrative persistence service | +| Cast and Legacy layout | existing Saga presentation/UI | +| Camera switching and safety | existing director/camera path | + +## Likely file changes + +### Add + +- `IdleSpectator/Narrative/NarrativeEventModels.cs` +- `IdleSpectator/Narrative/NarrativeEventRecorder.cs` +- `IdleSpectator/Narrative/NarrativePresentation.cs` +- `IdleSpectator/Narrative/NarrativeRenderer.cs` +- `IdleSpectator/Narrative/LegacyChapterBuilder.cs` +- `IdleSpectator/Narrative/NarrativePersistence.cs` +- `IdleSpectator/Narrative/NarrativePersistenceModels.cs` +- focused partial harness files if `AgentHarness.cs` is split during implementation + +### Major edits + +- `InterestCandidate.cs` +- `Events/EventFeedUtil.cs` +- relationship, combat, role, war, plot, status, and world-event feeds/patches +- `NarrativeModels.cs` +- `NarrativeDevelopmentRouter.cs` +- `NarrativeStoryStore.cs` +- `NarrativeThreadRules.cs` +- `NarrativeProse.cs` +- `StoryScheduler.cs` +- `EpisodeShotSelector.cs` +- `CaptionComposer.cs` +- `SagaProse.cs` +- `LifeSagaMemory.cs` +- `LifeSagaPresentation.cs` +- `LifeSagaSession.cs` +- `AgentHarness.cs` + +File names may be consolidated to match existing repository conventions. The ownership boundaries +and exit gates are the contract. + +## Implementation discipline + +1. Land phases in order; do not combine persistence and scheduler behavior in one cutover. +2. Keep compatibility adapters internal and temporary. +3. Every migrated event family receives deterministic tests before its old path is removed. +4. Each phase ends with a clean build and targeted regression. +5. Do not tune editorial weights to hide ingestion or prose defects. +6. Do not solve semantic duplication with substring-only suppression. String checks may be a final + safety net, but enums, roles, event ids, and evidence tags own correctness. +7. Do not persist presentation strings when they can be regenerated from semantic state. +8. Update this plan with checkpoint evidence as each phase completes. + +## Definition of done + +The plan is finished when a character can: + +1. enter the ensemble through a confirmed development; +2. appear in a caption whose Beat states that development once; +3. continue through a deliberate multi-beat episode; +4. accumulate a concise, non-redundant Legacy; +5. survive save, quit, restart, and reload with that history intact; and +6. return to the camera only when their story has genuinely changed. diff --git a/docs/story-planner.md b/docs/story-planner.md index fcf8f1e..a08bfc1 100644 --- a/docs/story-planner.md +++ b/docs/story-planner.md @@ -3,7 +3,8 @@ IdleSpectator commits to short multi-beat stories instead of channel-surfing ranked tips. Feeds, sticky ensembles, presentability, and `InterestDirector` stay in place. -`StoryPlanner` sits above the director and owns *which story* the next 30–90s belongs to. +`StoryScheduler` owns which durable character thread and episode purpose the next sequence belongs +to. `StoryPlanner` retains short-arc aftermath/crisis lifecycle and visual completion. ## Short-arc board (not the dossier rail) @@ -20,7 +21,8 @@ Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to Click toggles Prefer (soft favorite). AFK caption is Identity + Beat + Context via `CaptionComposer` (see [life-saga.md](life-saga.md)). Cast/Legacy depth is rail hover only (`LifeSagaViewController` + `LifeSagaPanel`). -Durable observed facts live in `LifeSagaMemory` (independent of ChronicleEnabled). +Durable observations live in `NarrativeEventStore`/`NarrativeStoryStore` and are persisted by +`NarrativePersistence` (independent of ChronicleEnabled). Hover temporarily pauses camera switching without disabling Idle Spectator. See [life-saga.md](life-saga.md). @@ -40,13 +42,19 @@ See also: [life-saga.md](life-saga.md), [scoring-model.md](scoring-model.md), [e ## Pipeline ```text -Feeds → InterestRegistry → StoryPlanner (boost / inject) - → InterestVariety.Pick → InterestDirector → CameraDirector +Feeds → InterestRegistry → StoryScheduler (thread + episode grammar) + → StoryPlanner (short-arc/crisis policy) → EpisodeShotSelector + → InterestDirector → CameraDirector ``` Director still owns camera, sticky maintain, and switch margins. Planner never calls `MoveCamera`. +Episodes track Establish, Development, Pressure, TurningPoint, Payoff, Consequence, +Reintroduction, and WorldContext roles. A shot must advance an uncovered role or be a permitted +critical interrupt; character texture never counts as fake progress. Meaningful-cut exposure debt +helps under-covered MCs without forcing weak footage, while repeated subject/action families cool. + ## Arc model | Kind | Climax | Hard hold | diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh index e9cbfd4..8439d62 100755 --- a/scripts/harness-run.sh +++ b/scripts/harness-run.sh @@ -38,6 +38,7 @@ REGRESSION_SCENARIOS=( discovery world_log camera_presentability + narrative_presentation_coverage event_suite chronicle_smoke activity_idle_smoke