From 739cc6492c04bd564d1bff7c7abfbaebc6fe5a86 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 23 Jul 2026 12:17:18 -0500 Subject: [PATCH] feat(spectator): make narrative threads own camera direction - Route confirmed life events into evidence-based threads, consequences, and Legacy chapters - Schedule episode shots with critical interrupts, replay guards, and a combat budget - Admit emerging main characters through story momentum instead of spectacle - Remove causal heat, hard-arc memory writes, and synthetic epilogue continuity - Expand narrative harness coverage, soak auditing, and product documentation --- IdleSpectator/ActivityLog.cs | 25 + IdleSpectator/ActorChroniclePatches.cs | 4 + IdleSpectator/AgentHarness.cs | 524 +++++++- IdleSpectator/CameraDirector.cs | 7 + IdleSpectator/Events/EventFeedUtil.cs | 3 + IdleSpectator/Events/Feeds/InterestFeeds.cs | 9 +- .../Patches/RelationshipEventPatches.cs | 6 + IdleSpectator/HarnessScenarios.cs | 232 +++- IdleSpectator/InterestCandidate.cs | 7 + .../InterestDirector.SwitchPolicy.cs | 38 + IdleSpectator/InterestDirector.cs | 15 + IdleSpectator/InterestScoring.cs | 7 - IdleSpectator/InterestVariety.cs | 109 ++ .../Narrative/EpisodeShotSelector.cs | 92 ++ IdleSpectator/Narrative/InterruptPolicy.cs | 94 ++ .../Narrative/NarrativeDevelopmentRouter.cs | 162 +++ .../Narrative/NarrativeFunctionCatalog.cs | 205 ++++ .../Narrative/NarrativeFunctionPolicy.cs | 110 ++ IdleSpectator/Narrative/NarrativeModels.cs | 262 ++++ IdleSpectator/Narrative/NarrativeProse.cs | 199 +++ .../Narrative/NarrativeStoryStore.cs | 375 ++++++ IdleSpectator/Narrative/NarrativeTelemetry.cs | 127 ++ .../Narrative/NarrativeThreadRules.cs | 265 ++++ IdleSpectator/Narrative/StoryScheduler.cs | 357 ++++++ IdleSpectator/Story/CaptionComposer.cs | 26 +- IdleSpectator/Story/CausalHeat.cs | 134 -- IdleSpectator/Story/LifeSagaMemory.cs | 59 +- IdleSpectator/Story/LifeSagaPresentation.cs | 165 +++ IdleSpectator/Story/LifeSagaRoster.cs | 234 +--- IdleSpectator/Story/LifeSagaSession.cs | 1 + IdleSpectator/Story/SagaProse.cs | 2 + IdleSpectator/Story/StoryPlanner.cs | 162 +-- IdleSpectator/WorldActivityScanner.cs | 6 - README.md | 10 +- docs/event-reason.md | 6 +- docs/life-saga.md | 12 +- docs/mc-story-director-rewrite-plan.md | 1078 +++++++++++++++++ docs/story-planner.md | 6 +- scripts/soak-audit-player-log.sh | 49 +- 39 files changed, 4505 insertions(+), 679 deletions(-) create mode 100644 IdleSpectator/Narrative/EpisodeShotSelector.cs create mode 100644 IdleSpectator/Narrative/InterruptPolicy.cs create mode 100644 IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs create mode 100644 IdleSpectator/Narrative/NarrativeFunctionCatalog.cs create mode 100644 IdleSpectator/Narrative/NarrativeFunctionPolicy.cs create mode 100644 IdleSpectator/Narrative/NarrativeModels.cs create mode 100644 IdleSpectator/Narrative/NarrativeProse.cs create mode 100644 IdleSpectator/Narrative/NarrativeStoryStore.cs create mode 100644 IdleSpectator/Narrative/NarrativeTelemetry.cs create mode 100644 IdleSpectator/Narrative/NarrativeThreadRules.cs create mode 100644 IdleSpectator/Narrative/StoryScheduler.cs delete mode 100644 IdleSpectator/Story/CausalHeat.cs create mode 100644 docs/mc-story-director-rewrite-plan.md diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs index 972025c..b33f8fd 100644 --- a/IdleSpectator/ActivityLog.cs +++ b/IdleSpectator/ActivityLog.cs @@ -61,6 +61,10 @@ public static class ActivityLog private static int _statusRefreshesSinceClear; private static string _lastStatusGainId = ""; private static string _lastStatusLossId = ""; + private static readonly Dictionary StatusGainHitsSinceReset = + new Dictionary(System.StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary StatusLossHitsSinceReset = + new Dictionary(System.StringComparer.OrdinalIgnoreCase); private static int _happinessSinceClear; private static string _lastHappinessId = ""; /// When non-zero, status counters only track this subject (harness isolation). @@ -78,6 +82,21 @@ public static class ActivityLog public static int StatusRefreshesSinceClear => _statusRefreshesSinceClear; public static string LastStatusGainId => _lastStatusGainId ?? ""; public static string LastStatusLossId => _lastStatusLossId ?? ""; + public static int StatusGainCount(string statusId) + { + return !string.IsNullOrEmpty(statusId) + && StatusGainHitsSinceReset.TryGetValue(statusId, out int count) + ? count + : 0; + } + + public static int StatusLossCount(string statusId) + { + return !string.IsNullOrEmpty(statusId) + && StatusLossHitsSinceReset.TryGetValue(statusId, out int count) + ? count + : 0; + } public static int HappinessSinceClear => _happinessSinceClear; public static string LastHappinessId => _lastHappinessId ?? ""; public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear; @@ -119,6 +138,8 @@ public static class ActivityLog _statusRefreshesSinceClear = 0; _lastStatusGainId = ""; _lastStatusLossId = ""; + StatusGainHitsSinceReset.Clear(); + StatusLossHitsSinceReset.Clear(); _statusCounterSubjectId = 0; } @@ -791,6 +812,8 @@ public static class ActivityLog { _statusGainsSinceClear++; _lastStatusGainId = id; + StatusGainHitsSinceReset.TryGetValue(id, out int hits); + StatusGainHitsSinceReset[id] = hits + 1; } } else @@ -799,6 +822,8 @@ public static class ActivityLog { _statusLossesSinceClear++; _lastStatusLossId = id; + StatusLossHitsSinceReset.TryGetValue(id, out int hits); + StatusLossHitsSinceReset[id] = hits + 1; } } diff --git a/IdleSpectator/ActorChroniclePatches.cs b/IdleSpectator/ActorChroniclePatches.cs index 553b150..2a2d0d8 100644 --- a/IdleSpectator/ActorChroniclePatches.cs +++ b/IdleSpectator/ActorChroniclePatches.cs @@ -13,6 +13,7 @@ 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); } @@ -53,6 +54,7 @@ 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); } @@ -62,6 +64,7 @@ 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); } @@ -75,6 +78,7 @@ 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 64919e9..d6f3dee 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2884,6 +2884,14 @@ public static class AgentHarness Emit(cmd, ok: true, detail: "variety_cleared"); break; + case "editorial_replay_probe": + { + bool ok = InterestVariety.HarnessProbeEditorialReplay(out string detail); + if (ok) _cmdOk++; else _cmdFail++; + Emit(cmd, ok, detail: detail); + break; + } + case "interest_story_clear": StoryPlanner.Clear(); _cmdOk++; @@ -2898,6 +2906,326 @@ public static class AgentHarness break; } + case "narrative_scheduler_probe": + { + bool ok = StoryScheduler.HarnessProbePolicy(out string detail); + if (ok) _cmdOk++; else _cmdFail++; + Emit(cmd, ok, detail: detail); + break; + } + + case "narrative_camera_cutover_probe": + { + bool ok = false; + string detail = ""; + try + { + Actor protagonist = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned; + Actor child = FindOtherAliveUnit(protagonist, "human"); + Actor outsider = null; + foreach (Actor candidate in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (candidate != null && candidate.isAlive() + && candidate != protagonist && candidate != child) + { + outsider = candidate; + break; + } + } + + if (protagonist == null || child == null || outsider == null) + { + detail = "missing actors"; + } + else + { + InterestDirector.HarnessEndCurrent("narrative_cutover_setup"); + InterestRegistry.Clear(); + NarrativeStoryStore.Clear(); + NarrativeDevelopmentRouter.ChildBorn(protagonist, child, "harness_cutover"); + + InterestCandidate unrelated = HarnessNarrativeCandidate( + "harness:narrative:unrelated", outsider, "Unrelated high event", 90f); + InterestCandidate related = HarnessNarrativeCandidate( + "harness:narrative:related", protagonist, "Lineage develops", 60f); + related.AssetId = "add_child"; + EventFeedUtil.RegisterCandidate(unrelated); + EventFeedUtil.RegisterCandidate(related); + StoryScheduler.Tick(Time.unscaledTime + 1f); + InterestCandidate first = InterestDirector.HarnessPeekNext(); + bool relatedWon = first != null && first.Key == related.Key; + bool watched = relatedWon && InterestDirector.HarnessForceSession(first); + InterestDirector.HarnessAgeCurrent(20f); + + InterestCandidate critical = HarnessNarrativeCandidate( + "harness:narrative:critical", outsider, "Kingdom shattered", 100f); + critical.Category = "Disaster"; + EventFeedUtil.RegisterCandidate(critical); + StoryScheduler.Tick(Time.unscaledTime + 2f); + InterestCandidate second = InterestDirector.HarnessPeekNext(); + bool criticalWon = second != null && second.Key == critical.Key; + ok = relatedWon && watched && criticalWon; + detail = "first=" + (first?.Key ?? "none") + + " watched=" + watched + + " second=" + (second?.Key ?? "none") + + " episode=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none") + + " pass=" + ok; + } + } + catch (Exception ex) + { + detail = "exception=" + ex.GetType().Name + ":" + ex.Message; + ok = false; + } + if (ok) _cmdOk++; else _cmdFail++; + Emit(cmd, ok, detail: detail); + break; + } + + case "narrative_story_inputs_probe": + { + Actor protagonist = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned; + Actor child = FindOtherAliveUnit(protagonist, "human"); + bool recorded = false; + bool admitted = false; + float potential = 0f; + if (protagonist != null && child != null) + { + NarrativeStoryStore.Clear(); + LifeSagaRoster.Clear(); + recorded = NarrativeDevelopmentRouter.ChildBorn(protagonist, child, "harness_story_inputs"); + potential = StoryScheduler.StoryPotential(EventFeedUtil.SafeId(protagonist)); + LifeSagaRoster.Tick(Time.unscaledTime + 2f); + LifeSagaSlot slot = LifeSagaRoster.Get(EventFeedUtil.SafeId(protagonist)); + admitted = slot != null && slot.AdmissionReason == LifeSagaAdmissionReason.EmergingStory; + } + + var consequence = new InterestCandidate + { + AssetId = "add_child", Category = "Family", EventStrength = 52f + }; + var escalation = new InterestCandidate + { + Completion = InterestCompletionKind.WarFront, Category = "War", EventStrength = 65f + }; + var texture = new InterestCandidate + { + LeadKind = InterestLeadKind.CharacterLed, + Completion = InterestCompletionKind.CharacterVignette, + EventStrength = 80f + }; + NarrativeFunction cf = NarrativeFunctionPolicy.Classify(consequence); + NarrativeFunction ef = NarrativeFunctionPolicy.Classify(escalation); + NarrativeFunction tf = NarrativeFunctionPolicy.Classify(texture); + bool ok = recorded && potential >= 6f && admitted + && cf == NarrativeFunction.Consequence + && ef == NarrativeFunction.Escalation + && tf == NarrativeFunction.CharacterTexture; + if (ok) _cmdOk++; else _cmdFail++; + Emit(cmd, ok, detail: "recorded=" + recorded + + " potential=" + potential.ToString("0.0") + + " admitted=" + admitted + + " functions=" + cf + "/" + ef + "/" + tf); + break; + } + + case "narrative_soak_seed": + { + Actor protagonist = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned; + Actor child = FindOtherAliveUnit(protagonist, "human"); + bool ok = protagonist != null && child != null; + if (ok) + { + InterestDirector.HarnessEndCurrent("narrative_soak_seed"); + InterestRegistry.Clear(); + NarrativeStoryStore.Clear(); + NarrativeDevelopmentRouter.ChildBorn(protagonist, child, "harness_story_soak"); + InterestCandidate related = HarnessNarrativeCandidate( + "harness:narrative:add_child", protagonist, "Welcomes a child", 58f); + related.AssetId = "add_child"; + related.Category = "Family"; + EventFeedUtil.RegisterCandidate(related); + } + + if (ok) _cmdOk++; else _cmdFail++; + Emit(cmd, ok, detail: "seeded=" + ok + + " protagonist=" + EventFeedUtil.SafeId(protagonist) + + " child=" + EventFeedUtil.SafeId(child)); + break; + } + + case "narrative_telemetry_probe": + { + bool ok = NarrativeTelemetry.Count > 0 + && NarrativeTelemetry.RelatedProposalCount > 0 + && NarrativeTelemetry.ComparedCount > 0 + && NarrativeTelemetry.RelatedActualCount > 0; + if (ok) _cmdOk++; else _cmdFail++; + Emit(cmd, ok, detail: NarrativeTelemetry.Summary + + " episode=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none")); + break; + } + + case "narrative_catalog_probe": + { + NarrativeFunctionCatalog.ClearCoverage(); + var child = new InterestCandidate { Key = "catalog:child", AssetId = "add_child" }; + var war = new InterestCandidate + { + Key = "catalog:war", AssetId = "live_war", Completion = InterestCompletionKind.WarFront + }; + var daily = new InterestCandidate { Key = "catalog:daily", HappinessEffectId = "just_slept" }; + var disaster = new InterestCandidate { Key = "catalog:disaster", AssetId = "disaster_tornado" }; + NarrativeFunctionCatalog.Stamp(child); + NarrativeFunctionCatalog.Stamp(war); + NarrativeFunctionCatalog.Stamp(daily); + NarrativeFunctionCatalog.Stamp(disaster); + bool ok = child.NarrativeFunction == NarrativeFunction.Consequence + && war.NarrativeFunction == NarrativeFunction.Escalation + && daily.NarrativeFunction == NarrativeFunction.CharacterTexture + && disaster.NarrativeFunction == NarrativeFunction.WorldContext + && NarrativeFunctionCatalog.ExplicitCount == 4 + && NarrativeFunctionCatalog.FallbackCount == 0; + if (ok) _cmdOk++; else _cmdFail++; + Emit(cmd, ok, detail: NarrativeFunctionCatalog.CoverageSummary); + break; + } + + case "narrative_natural_soak_begin": + InterestDirector.HarnessEndCurrent("narrative_natural_soak"); + InterestRegistry.Clear(); + NarrativeStoryStore.Clear(); + NarrativeFunctionCatalog.ClearCoverage(); + NarrativeTelemetry.Clear(); + _cmdOk++; + Emit(cmd, ok: true, detail: "natural_soak_reset"); + break; + + case "narrative_natural_soak_probe": + { + int typed = NarrativeFunctionCatalog.ExplicitCount + NarrativeFunctionCatalog.StructuralCount; + float typedRatio = NarrativeFunctionCatalog.ObservedCount > 0 + ? typed / (float)NarrativeFunctionCatalog.ObservedCount + : 0f; + bool cameraHealthy = MoveCamera.hasFocusUnit() + && MoveCamera._focus_unit != null + && MoveCamera._focus_unit.isAlive(); + bool ok = NarrativeFunctionCatalog.ObservedCount > 0 + && typed > 0 + && typedRatio >= 0.25f + && cameraHealthy; + if (ok) _cmdOk++; else _cmdFail++; + Emit(cmd, ok, detail: NarrativeFunctionCatalog.CoverageSummary + + " typedRatio=" + typedRatio.ToString("0.00") + + " cameraHealthy=" + cameraHealthy + + " telemetry=" + NarrativeTelemetry.Summary + + " episode=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none")); + break; + } + + case "narrative_multithread_probe": + { + bool ok = false; + string detail = ""; + try + { + var actors = new List(6); + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor != null && actor.isAlive() && !actors.Contains(actor)) actors.Add(actor); + if (actors.Count >= 5) break; + } + + if (actors.Count < 5) + { + detail = "actors=" + actors.Count; + } + else + { + Actor a = actors[0]; + Actor childA = actors[1]; + Actor b = actors[2]; + Actor childB = actors[3]; + Actor outsider = actors[4]; + long aid = EventFeedUtil.SafeId(a); + long bid = EventFeedUtil.SafeId(b); + + InterestDirector.HarnessEndCurrent("narrative_multithread_setup"); + InterestRegistry.Clear(); + NarrativeStoryStore.Clear(); + LifeSagaRoster.Clear(); + LifeSagaRoster.HarnessForceAdmit(a); + LifeSagaRoster.SetPrefer(aid, true); + NarrativeDevelopmentRouter.ChildBorn(a, childA, "harness_multithread_a"); + NarrativeDevelopmentRouter.ChildBorn(b, childB, "harness_multithread_b"); + + InterestCandidate shotA = HarnessNarrativeCandidate( + "harness:multi:a", a, "Lineage A develops", 62f); + shotA.AssetId = "add_child"; + InterestCandidate shotB = HarnessNarrativeCandidate( + "harness:multi:b", b, "Lineage B develops", 68f); + shotB.AssetId = "add_child"; + EventFeedUtil.RegisterCandidate(shotA); + EventFeedUtil.RegisterCandidate(shotB); + float now = Time.unscaledTime; + StoryScheduler.Tick(now + 1f); + string firstThread = StoryScheduler.ActiveEpisode?.ThreadId ?? ""; + InterestCandidate first = InterestDirector.HarnessPeekNext(); + bool startsA = StoryScheduler.ActiveEpisode?.ProtagonistId == aid + && first?.Key == shotA.Key; + bool watchedA = startsA && InterestDirector.HarnessForceSession(first); + + NarrativeDevelopmentRouter.Role(b, "become_king", true, "harness_multithread_b"); + StoryScheduler.Tick(now + 2f); + bool retainedA = StoryScheduler.ActiveEpisode?.ThreadId == firstThread; + + InterestCandidate critical = HarnessNarrativeCandidate( + "harness:multi:critical", outsider, "Kingdom shattered", 100f); + critical.Category = "Disaster"; + EventFeedUtil.RegisterCandidate(critical); + InterestDirector.HarnessAgeCurrent(20f); + StoryScheduler.Tick(now + 3f); + InterestCandidate criticalPick = InterestDirector.HarnessPeekNext(); + bool criticalWon = criticalPick?.Key == critical.Key; + bool watchedCritical = criticalWon && InterestDirector.HarnessForceSession(criticalPick); + bool survivedInterrupt = StoryScheduler.ActiveEpisode?.ThreadId == firstThread; + + InterestDirector.HarnessEndCurrent("critical_complete"); + + InterestCandidate resumeA = HarnessNarrativeCandidate( + "harness:multi:a:resume", a, "Lineage A continues", 64f); + resumeA.AssetId = "add_child"; + EventFeedUtil.RegisterCandidate(resumeA); + StoryScheduler.Tick(now + 4f); + InterestCandidate resumed = StoryScheduler.SelectForCamera( + new List { resumeA }, current: null, now: now + 4f); + bool resumedA = resumed?.Key == resumeA.Key + && StoryScheduler.ActiveEpisode?.ThreadId == firstThread; + + NarrativeThread closing = NarrativeStoryStore.Thread(firstThread); + if (closing != null) closing.Status = NarrativeThreadStatus.Abandoned; + StoryScheduler.Tick(now + 20f); + bool handedToB = StoryScheduler.ActiveEpisode?.ProtagonistId == bid; + ok = startsA && watchedA && retainedA && criticalWon && watchedCritical + && survivedInterrupt && resumedA && handedToB; + detail = "startsA=" + startsA + " retainedA=" + retainedA + + " critical=" + criticalWon + "/" + watchedCritical + + " survived=" + survivedInterrupt + " resumedA=" + resumedA + + " handedToB=" + handedToB + + " firstThread=" + firstThread + + " finalThread=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none"); + } + } + catch (Exception ex) + { + detail = "exception=" + ex.GetType().Name + ":" + ex.Message; + ok = false; + } + if (ok) _cmdOk++; else _cmdFail++; + Emit(cmd, ok, detail: detail); + break; + } + case "caption_status_banner_probe": { Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; @@ -3238,6 +3566,69 @@ public static class AgentHarness break; } + case "saga_legacy_combat_probe": + { + Actor killer = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + int recorded = 0; + if (killer != null && killer.isAlive() && World.world?.units != null) + { + try + { + foreach (Actor victim in World.world.units) + { + if (victim == null || !victim.isAlive() || victim == killer) + { + continue; + } + + LifeSagaMemory.RecordKill( + killer, victim, "harness_combat_summary:" + recorded); + recorded++; + if (recorded >= 3) break; + } + } + catch + { + // failure is reported by the assertions below + } + } + + LifeSagaViewModel model = LifeSagaPresentation.Build(EventFeedUtil.SafeId(killer)); + int summaryCount = 0; + int rawKillCount = 0; + string lines = ""; + if (model != null) + { + for (int i = 0; i < model.Legacy.Count; i++) + { + string line = model.Legacy[i]?.Line ?? ""; + if (string.IsNullOrEmpty(line)) continue; + if (lines.Length > 0) lines += " | "; + lines += line; + if (line.IndexOf( + "Slew 3 opponents across repeated clashes", + StringComparison.OrdinalIgnoreCase) >= 0) + { + summaryCount++; + } + else if (model.Legacy[i].Kind == LifeSagaFactKind.Kill) + { + rawKillCount++; + } + } + } + + bool ok = recorded == 3 && summaryCount == 1 && rawKillCount == 0; + if (ok) _cmdOk++; + else _cmdFail++; + Emit( + cmd, + ok, + detail: $"recorded={recorded} summary={summaryCount} rawKills={rawKillCount}" + + $" legacy='{lines}'"); + break; + } + case "interest_bind_related_partner": { InterestCandidate cur = InterestDirector.CurrentCandidate; @@ -3403,39 +3794,6 @@ public static class AgentHarness break; } - case "saga_consider_hard_focus": - { - Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; - long id = EventFeedUtil.SafeId(focus); - float heat = 6f; - if (!string.IsNullOrEmpty(cmd.value) - && float.TryParse( - cmd.value.Trim(), - System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, - out float parsed) - && parsed > 0f) - { - heat = parsed; - } - - if (id == 0) - { - _cmdFail++; - Emit(cmd, ok: false, detail: "no focus"); - break; - } - - bool admitted = LifeSagaRoster.ConsiderAdmit(id, heat: heat, hardArcAdmit: true); - _cmdOk++; - Emit( - cmd, - ok: true, - detail: - $"hardAdmit id={id} on={admitted} heat={heat:0.##} roster={LifeSagaRoster.Count}"); - break; - } - case "saga_rail_refresh": { LifeSagaRoster.Tick(Time.unscaledTime); @@ -3594,11 +3952,6 @@ public static class AgentHarness { kind = LifeSagaFactKind.RoleChange; } - else if (kindRaw.IndexOf("combat", StringComparison.OrdinalIgnoreCase) >= 0 - || kindRaw.IndexOf("hardarc", StringComparison.OrdinalIgnoreCase) >= 0) - { - kind = LifeSagaFactKind.HardArcCombat; - } else if (kindRaw.IndexOf("parent", StringComparison.OrdinalIgnoreCase) >= 0 || kindRaw.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0) { @@ -3660,16 +4013,6 @@ public static class AgentHarness fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus)); } - else if (kind == LifeSagaFactKind.HardArcCombat) - { - LifeSagaMemory.RecordHardArc( - EventFeedUtil.SafeId(focus), - StoryArcKind.CombatDuel, - string.IsNullOrEmpty(cmd.value) ? "A fight marked them" : cmd.value, - relatedId: EventFeedUtil.SafeId(other), - provenance: "harness"); - fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus)); - } else if (kind == LifeSagaFactKind.ParentChild) { if (other == null || !other.isAlive() || other == focus) @@ -4449,6 +4792,7 @@ public static class AgentHarness { LifeSagaRoster.Clear(); LifeSagaMemory.Clear(); + NarrativeStoryStore.Clear(); } string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL"; @@ -4796,6 +5140,15 @@ public static class AgentHarness string assetId = cmd.asset; Actor follow = null; + if (string.IsNullOrEmpty(assetId) && MoveCamera.hasFocusUnit()) + { + Actor focused = MoveCamera._focus_unit; + if (focused != null && focused.isAlive()) + { + follow = focused; + } + } + if (!string.IsNullOrEmpty(assetId)) { follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId); @@ -8815,6 +9168,37 @@ public static class AgentHarness Emit(cmd, ok, detail: detail); } + private static InterestCandidate HarnessNarrativeCandidate( + string key, + Actor subject, + string label, + float eventStrength) + { + long id = EventFeedUtil.SafeId(subject); + return new InterestCandidate + { + Key = key ?? "", + Source = "harness", + Category = "Story", + AssetId = key ?? "", + Label = EventPresentability.EnsureSubjectLedLabel(subject, label ?? ""), + FollowUnit = subject, + SubjectId = id, + Position = subject != null ? subject.current_position : Vector3.zero, + EventStrength = eventStrength, + CharacterSignificance = 5f, + VisualConfidence = 0.8f, + Novelty = 1f, + Completion = InterestCompletionKind.FixedDwell, + MinWatch = 1f, + MaxWatch = 15f, + CreatedAt = Time.unscaledTime, + LastSeenAt = Time.unscaledTime, + ExpiresAt = Time.unscaledTime + 30f, + Resumable = false + }; + } + private static void DoAssert(HarnessCommand cmd) { string expect = (cmd.expect ?? "").Trim().ToLowerInvariant(); @@ -10338,6 +10722,19 @@ public static class AgentHarness detail = $"tip='{tip}' contains='{needle}'"; break; } + case "tip_contains_or_story_hold": + { + string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; + string tip = CameraDirector.LastWatchLabel ?? ""; + bool contains = !string.IsNullOrEmpty(needle) + && tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0; + bool held = StoryScheduler.ActiveEpisode != null + && !contains; + pass = contains || held; + detail = $"tip='{tip}' contains='{needle}' storyHold={held}" + + $" episode={StoryScheduler.ActiveEpisode?.ThreadId ?? "none"}"; + break; + } case "enabled": case "setting_enabled": { @@ -12308,10 +12705,14 @@ public static class AgentHarness case "activity_status_gain_count": { int want = ParseCountExpect(cmd, defaultValue: 1); - int got = ActivityLog.StatusGainsSinceClear; + string statusId = cmd.asset ?? ""; + int got = string.IsNullOrEmpty(statusId) + ? ActivityLog.StatusGainsSinceClear + : ActivityLog.StatusGainCount(statusId); string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant(); pass = mode == "min" ? got >= want : got == want; - detail = $"gains={got} want={want} mode={mode} last={ActivityLog.LastStatusGainId}"; + detail = $"gains={got} want={want} mode={mode} status={statusId}" + + $" total={ActivityLog.StatusGainsSinceClear} last={ActivityLog.LastStatusGainId}"; break; } case "activity_status_loss_count": @@ -12365,15 +12766,28 @@ public static class AgentHarness string want = cmd.value ?? ""; string got = ""; - IReadOnlyList list = ActivityLog.LatestForSubject(id, 1); - if (list != null && list.Count > 0 && list[0] != null) + bool found = false; + IReadOnlyList list = ActivityLog.LatestForSubject(id, 12); + if (list != null) { - got = list[0].TaskId ?? ""; + for (int i = 0; i < list.Count; i++) + { + string taskId = list[i]?.TaskId ?? ""; + if (i == 0) + { + got = taskId; + } + if (taskId.Equals(want, StringComparison.OrdinalIgnoreCase)) + { + found = true; + break; + } + } } pass = !string.IsNullOrEmpty(want) - && got.Equals(want, StringComparison.OrdinalIgnoreCase); - detail = $"got='{got}' want='{want}'"; + && found; + detail = $"latest='{got}' found={found} want='{want}'"; break; } case "activity_prose_showcase": diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index 7a2e7ae..f7a9f1a 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -136,6 +136,13 @@ public static class CameraDirector MoveCamera.clearFocusUnitOnly(); } + public static void ClearWatchReason() + { + LastWatchLabel = ""; + LastWatchAssetId = ""; + LastFormattedWatchTip = "Watching"; + } + public static void PanTo(Vector3 pos) { PanCamera(pos); diff --git a/IdleSpectator/Events/EventFeedUtil.cs b/IdleSpectator/Events/EventFeedUtil.cs index ddd532e..e2949c5 100644 --- a/IdleSpectator/Events/EventFeedUtil.cs +++ b/IdleSpectator/Events/EventFeedUtil.cs @@ -103,6 +103,7 @@ public static class EventFeedUtil MaxWatch = maxWatch, Completion = completion }; + NarrativeFunctionCatalog.Stamp(candidate); if (!EventPresentability.WouldShow(candidate)) { InterestDropLog.Record("unpresentable", candidate.Label ?? key); @@ -151,6 +152,8 @@ public static class EventFeedUtil candidate.RelatedId = SafeId(candidate.RelatedUnit); } + NarrativeFunctionCatalog.Stamp(candidate); + // Harness injects synthetic tip labels for director tests; dossier still blanks them. // Production feeds (Source != harness) must be presentable to enter the registry. if (!IsHarnessSource(candidate.Source) && !EventPresentability.WouldShow(candidate)) diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index d572e94..def12a4 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -134,11 +134,10 @@ public static class InterestFeeds float evtSeed = entry.EventStrength; Vector3 position = message.getLocation(); - Actor unit = null; - if (message.hasFollowLocation()) - { - unit = message.unit; - } + // The message's unit is an explicit event participant even when the WorldLog asset + // suppresses vanilla follow-location UI (for example alien invasion). Preserve that + // ownership so camera-worthy events do not become anchorless and disappear. + Actor unit = message.unit; string assetId = message.asset_id ?? entry.Id ?? "worldlog"; string kingdom = message.special1 ?? ""; diff --git a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs index eb5169d..6f8815d 100644 --- a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs +++ b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs @@ -71,11 +71,13 @@ public static class RelationshipEventPatches if (pActor == null) { LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)"); + NarrativeDevelopmentRouter.BondBroken(__instance, __state, "setLover(null)"); RelationshipInterestFeed.Emit("clear_lover", __instance, null); return; } LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover"); + NarrativeDevelopmentRouter.BondFormed(__instance, pActor, "setLover"); RelationshipInterestFeed.Emit("set_lover", __instance, pActor); } @@ -126,6 +128,7 @@ public static class RelationshipEventPatches } LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup"); + NarrativeDevelopmentRouter.BondBroken(unit, lover, "dead_lover_cleanup"); RelationshipInterestFeed.Emit("clear_lover", unit, null); } catch @@ -168,6 +171,7 @@ public static class RelationshipEventPatches () => { LifeSagaMemory.RecordParentChild(parent, child); + NarrativeDevelopmentRouter.ChildBorn(parent, child, "setParent"); RelationshipInterestFeed.Emit("add_child", parent, child); }); } @@ -190,6 +194,7 @@ public static class RelationshipEventPatches } LifeSagaMemory.RecordFounding(anchor, "family", familyKey, "new_family"); + NarrativeDevelopmentRouter.Founding(anchor, "family", familyKey, "new_family"); } RelationshipInterestFeed.EmitFamily("new_family", __result, anchor); @@ -450,6 +455,7 @@ 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 b614830..968e071 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -156,6 +156,30 @@ internal static class HarnessScenarios return StoryBoardParkResume(); case "story_cross_arc_correlation": return StoryCrossArcCorrelation(); + case "narrative_scheduler_policy": + case "narrative_interrupt_policy": + return NarrativeSchedulerPolicy(); + case "narrative_camera_cutover": + case "story_first_camera": + return NarrativeCameraCutover(); + case "narrative_story_inputs": + case "story_potential_roster": + return NarrativeStoryInputs(); + case "narrative_story_first_soak": + case "story_first_soak": + return NarrativeStoryFirstSoak(); + case "narrative_catalog": + case "narrative_catalog_policy": + return NarrativeCatalogPolicy(); + case "narrative_natural_soak": + case "story_first_natural_soak": + return NarrativeNaturalSoak(); + case "narrative_multithread_stress": + case "story_first_multithread": + return NarrativeMultiThreadStress(); + case "narrative_editorial_replay": + case "story_first_replay_guard": + return NarrativeEditorialReplay(); case "story_arc_preempt_resume": return StoryArcPreemptResume(); case "story_ledger_revisit": @@ -183,12 +207,12 @@ internal static class HarnessScenarios return SagaCrossMcKill(); case "saga_rival_not_single_kill": return SagaRivalNotSingleKill(); + case "saga_legacy_combat_summary": + return SagaLegacyCombatSummary(); case "saga_rival_rematch": return SagaRivalRematch(); case "saga_prefer_cuts_ambient": return SagaPreferCutsAmbient(); - case "saga_hard_arc_escalates": - return SagaHardArcEscalates(); case "saga_overview_has_mc": return SagaOverviewHasMc(); case "saga_snapshot_popover": @@ -420,6 +444,14 @@ internal static class HarnessScenarios Nested("reg_story_lore_pause", "story_lore_pause_keeps"), Nested("reg_story_board_park", "story_board_park_resume"), Nested("reg_story_cross_arc", "story_cross_arc_correlation"), + Nested("reg_narrative_scheduler", "narrative_scheduler_policy"), + Nested("reg_narrative_camera", "narrative_camera_cutover"), + Nested("reg_narrative_inputs", "narrative_story_inputs"), + Nested("reg_narrative_soak", "narrative_story_first_soak"), + Nested("reg_narrative_catalog", "narrative_catalog_policy"), + Nested("reg_narrative_natural", "narrative_natural_soak"), + Nested("reg_narrative_multithread", "narrative_multithread_stress"), + Nested("reg_narrative_replay", "narrative_editorial_replay"), Nested("reg_caption_status_banner", "caption_status_banner_live"), Nested("reg_saga_relation_revision", "saga_relation_cache_revision"), Nested("reg_saga_cast_dedupe", "saga_cast_dedupe_scoped"), @@ -438,7 +470,6 @@ internal static class HarnessScenarios Nested("reg_saga_rival_kill", "saga_rival_not_single_kill"), Nested("reg_saga_rival_rematch", "saga_rival_rematch"), Nested("reg_saga_ambient_cut", "saga_prefer_cuts_ambient"), - Nested("reg_saga_hard_arc_heat", "saga_hard_arc_escalates"), Nested("reg_saga_overview", "saga_overview_has_mc"), Nested("reg_saga_snapshot", "saga_adaptive_dossier"), Nested("reg_saga_memory", "saga_memory_survives"), @@ -447,6 +478,7 @@ internal static class HarnessScenarios Nested("reg_saga_cast_kin", "saga_cast_kin_non_pack_lens"), Nested("reg_saga_stake_role", "saga_stake_from_role_live"), Nested("reg_saga_legacy_quality", "saga_legacy_quality"), + Nested("reg_saga_legacy_combat", "saga_legacy_combat_summary"), Nested("reg_saga_session_reset", "saga_session_reset"), Nested("reg_saga_hover_pause", "saga_hover_read_pause"), Nested("reg_saga_diversity", "saga_roster_diversity"), @@ -633,14 +665,14 @@ internal static class HarnessScenarios // Gain path (confused is widely applicable across assets) Step("st12", "status_apply", value: "confused"), - Step("st13", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"), + Step("st13", "assert", expect: "activity_status_gain_count", value: "1", label: "exact", asset: "confused"), Step("st14", "assert", expect: "activity_log_contains", value: "confused"), Step("st15", "assert", expect: "activity_status_task_id", value: "status_gain:confused"), Step("st16", "assert", expect: "activity_status_active", value: "confused", label: "true"), // Refresh must not create another gain line Step("st17", "status_apply", value: "confused"), - Step("st18", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"), + Step("st18", "assert", expect: "activity_status_gain_count", value: "1", label: "exact", asset: "confused"), Step("st19", "assert", expect: "activity_status_refresh_count", value: "1", label: "min"), // Second distinct gain (min: world may emit an extra status mid-suite) @@ -666,7 +698,7 @@ internal static class HarnessScenarios Step("st28c", "activity_status_reset"), Step("st28d", "fast_timing", value: "true"), Step("st29", "status_apply", value: "surprised", label: "0.25"), - Step("st30", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"), + Step("st30", "assert", expect: "activity_status_gain_count", value: "1", label: "exact", asset: "surprised"), Step("st31", "wait", wait: 1.25f), Step("st32", "assert", expect: "activity_status_active", value: "surprised", label: "false"), Step("st33", "assert", expect: "activity_status_loss_count", value: "1", label: "min"), @@ -3124,6 +3156,124 @@ internal static class HarnessScenarios }; } + private static List NarrativeSchedulerPolicy() + { + return new List + { + Step("nsp0", "dismiss_windows"), + Step("nsp1", "wait_world"), + Step("nsp2", "narrative_scheduler_probe"), + Step("nsp3", "assert", expect: "no_bad"), + Step("nsp99", "snapshot") + }; + } + + private static List NarrativeCameraCutover() + { + return new List + { + Step("ncc0", "dismiss_windows"), + Step("ncc1", "wait_world"), + Step("ncc2", "spawn", asset: "human", count: 3), + Step("ncc3", "focus", asset: "human"), + Step("ncc4", "narrative_camera_cutover_probe"), + Step("ncc5", "assert", expect: "no_bad"), + Step("ncc99", "snapshot") + }; + } + + private static List NarrativeStoryInputs() + { + return new List + { + Step("nsi0", "dismiss_windows"), + Step("nsi1", "wait_world"), + Step("nsi2", "spawn", asset: "human", count: 3), + Step("nsi3", "focus", asset: "human"), + Step("nsi4", "narrative_story_inputs_probe"), + Step("nsi5", "assert", expect: "no_bad"), + Step("nsi99", "snapshot") + }; + } + + private static List NarrativeStoryFirstSoak() + { + return new List + { + Step("nss0", "dismiss_windows"), + Step("nss1", "wait_world"), + Step("nss2", "spawn", asset: "human", count: 4), + Step("nss3", "spectator", value: "off"), + Step("nss4", "spectator", value: "on"), + Step("nss5", "focus", asset: "human"), + Step("nss8", "narrative_soak_seed"), + Step("nss9", "director_run", wait: 8f), + Step("nss10", "narrative_telemetry_probe"), + Step("nss11", "assert", expect: "has_focus"), + Step("nss12", "assert", expect: "tip_matches_focus"), + Step("nss13", "assert", expect: "no_bad"), + Step("nss99", "snapshot") + }; + } + + private static List NarrativeCatalogPolicy() + { + return new List + { + Step("ncp0", "dismiss_windows"), + Step("ncp1", "wait_world"), + Step("ncp2", "narrative_catalog_probe"), + Step("ncp3", "assert", expect: "no_bad"), + Step("ncp99", "snapshot") + }; + } + + private static List NarrativeNaturalSoak() + { + return new List + { + Step("nns0", "dismiss_windows"), + Step("nns1", "wait_world"), + Step("nns2", "spawn", asset: "human", count: 12), + Step("nns3", "spectator", value: "off"), + Step("nns4", "spectator", value: "on"), + Step("nns5", "focus", asset: "human"), + Step("nns6", "force_live_feeds", value: "true"), + Step("nns9", "narrative_natural_soak_begin"), + Step("nns10", "director_run", wait: 20f), + Step("nns11", "narrative_natural_soak_probe"), + Step("nns12", "assert", expect: "tip_matches_focus"), + Step("nns13", "assert", expect: "no_bad"), + Step("nns15", "force_live_feeds", value: "false"), + Step("nns99", "snapshot") + }; + } + + private static List NarrativeMultiThreadStress() + { + return new List + { + Step("nmt0", "dismiss_windows"), + Step("nmt1", "wait_world"), + Step("nmt2", "spawn", asset: "human", count: 6), + Step("nmt3", "narrative_multithread_probe"), + Step("nmt4", "assert", expect: "no_bad"), + Step("nmt99", "snapshot") + }; + } + + private static List NarrativeEditorialReplay() + { + return new List + { + Step("ner0", "dismiss_windows"), + Step("ner1", "wait_world"), + Step("ner2", "editorial_replay_probe"), + Step("ner3", "assert", expect: "no_bad"), + Step("ner99", "snapshot") + }; + } + /// /// Parenthood cools world-wide for a while; a second villager cannot chain the same tip. /// @@ -3479,6 +3629,20 @@ internal static class HarnessScenarios }; } + private static List SagaLegacyCombatSummary() + { + return new List + { + Step("slcs0", "dismiss_windows"), + Step("slcs1", "wait_world"), + Step("slcs2", "spawn", asset: "human", count: 4), + Step("slcs3", "focus", asset: "human"), + Step("slcs4", "interest_saga_clear"), + Step("slcs5", "saga_legacy_combat_probe"), + Step("slcs99", "snapshot"), + }; + } + /// Threshold living rematches earn RivalEarned. private static List SagaRivalRematch() { @@ -3535,40 +3699,6 @@ internal static class HarnessScenarios }; } - /// - /// Prolonged hard-arc heat must beat a full Cap of stiff slots; one-shot heat 1.5 must not. - /// Mirrors soak: stranger duel stuck off-rail until admit heat escalates. - /// - private static List SagaHardArcEscalates() - { - return new List - { - Step("she0", "dismiss_windows"), - Step("she1", "wait_world"), - Step("she2", "set_setting", expect: "enabled", value: "true"), - Step("she3", "fast_timing", value: "true"), - Step("she4", "spawn", asset: "human", count: 14), - Step("she5", "spectator", value: "off"), - Step("she6", "spectator", value: "on"), - Step("she7", "interest_saga_clear"), - Step("she8", "pick_unit", asset: "human"), - Step("she9", "happiness_remember_partner"), - Step("she10", "saga_fill_cap", asset: "human"), - Step("she50", "assert", expect: "saga_roster_count", value: "10"), - Step("she51", "saga_stiffen_roster", value: "8"), - Step("she60", "focus_partner"), - Step("she62", "assert", expect: "saga_has_focus", value: "false"), - Step("she63", "saga_consider_hard_focus", value: "1.5"), - Step("she64", "assert", expect: "saga_has_focus", value: "false"), - Step("she65", "saga_consider_hard_focus", value: "12"), - Step("she66", "assert", expect: "saga_has_focus"), - Step("she67", "saga_rail_refresh"), - Step("she68", "assert", expect: "saga_rail_active_highlight", value: "true"), - Step("she90", "fast_timing", value: "false"), - Step("she99", "snapshot"), - }; - } - /// /// Compact saga snapshot: admission headline, collapsible layout, structured chapters, /// no planner jargon / trait descs / Chronicle dumps. @@ -3874,7 +4004,7 @@ internal static class HarnessScenarios }; } - /// Live roster role rise updates Identity without duplicating its prior combat stake. + /// Live roster role rise updates Identity and the current supported stake. private static List SagaStakeFromRoleLive() { return new List @@ -3890,8 +4020,6 @@ internal static class HarnessScenarios Step("srl8", "pick_unit", asset: "human"), Step("srl9", "focus", asset: "human"), Step("srl10", "saga_force_admit_focus"), - Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "Survived a hard fight"), - Step("srl12", "assert", expect: "saga_stake_contains", value: "Survived a hard fight|hard fight"), Step("srl13", "saga_role_rise_focus", value: "become_clan_chief"), Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"), Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"), @@ -4177,7 +4305,7 @@ internal static class HarnessScenarios } /// - /// Extended roles: singleton dragon overview + hard-arc nobody admit path. + /// Extended roles: singleton dragon overview + explicit ordinary-character admission. /// private static List SagaAdmitRoles() { @@ -4199,10 +4327,10 @@ internal static class HarnessScenarios Step("sar12", "assert", expect: "saga_has_focus"), Step("sar13", "saga_rail_refresh"), Step("sar14", "assert", expect: "saga_role_tip", value: "Lone|Last of their kind|Dragon"), - // Nobody human may enter only via hard-arc ConsiderAdmit. + // Harness admission proves an ordinary human can occupy the ensemble. Step("sar20", "pick_unit", asset: "human"), Step("sar21", "focus", asset: "human"), - Step("sar22", "saga_consider_hard_focus"), + Step("sar22", "saga_force_admit_focus"), Step("sar23", "assert", expect: "saga_has_focus"), Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"), Step("sar90", "fast_timing", value: "false"), @@ -4210,7 +4338,7 @@ internal static class HarnessScenarios }; } - /// Full Cap: Prefer pin survives; hotter hard-arc admit displaces weakest non-pin. + /// Full Cap: Prefer pin survives; a new admitted character displaces the weakest non-pin. private static List SagaReplaceWeaker() { return new List @@ -4258,10 +4386,10 @@ internal static class HarnessScenarios Step("srw46", "saga_force_admit_focus"), Step("srw50", "assert", expect: "saga_roster_count", value: "10"), Step("srw51", "saga_weaken_nonprefer"), - // 11th human challenges Cap via hard-arc admit path. + // 11th human challenges the full ensemble. Step("srw60", "pick_unit", asset: "human", value: "other"), Step("srw61", "focus", asset: "human"), - Step("srw62", "saga_consider_hard_focus"), + Step("srw62", "saga_force_admit_focus"), Step("srw63", "assert", expect: "saga_has_focus"), Step("srw64", "assert", expect: "saga_roster_count", value: "10"), // Prefer pin still present; challenger focus itself is not Prefer. @@ -5019,8 +5147,8 @@ internal static class HarnessScenarios Step("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"), Step("wl11", "age_current", wait: 1.2f), Step("wl12", "director_run", wait: 1.2f), - Step("wl13", "assert", expect: "tip_contains", value: "kingdom_new"), - Step("wl14", "assert", expect: "tip_contains", value: "kingdom_new"), + Step("wl13", "assert", expect: "tip_contains_or_story_hold", value: "kingdom_new"), + Step("wl14", "assert", expect: "tip_contains_or_story_hold", value: "kingdom_new"), Step("wl20", "trigger_interest", asset: "auto", label: "Epic: diplomacy_war_started", tier: "Epic"), Step("wl21", "age_current", wait: 1.2f), @@ -5344,7 +5472,7 @@ internal static class HarnessScenarios Step("act4", "set_setting", expect: "chronicle_enabled", value: "true"), Step("act5", "chronicle_clear"), Step("act6", "activity_clear"), - Step("act7", "pick_unit", asset: "auto"), + Step("act7", "spawn", asset: "human"), Step("act8", "spectator", value: "off"), Step("act9", "spectator", value: "on"), Step("act10", "focus", asset: "auto"), diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index ba10635..399c8aa 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -53,6 +53,10 @@ public sealed class InterestCandidate public float Novelty = 1f; public float VisualConfidence = 0.5f; public float TotalScore; + /// Editorial role assigned by the narrative catalog at registry intake. + public NarrativeFunction NarrativeFunction; + public bool HasNarrativeFunction; + public string NarrativeFunctionSource = ""; /// Live fighters / participants in a cluster (battles, multi-unit events). public int ParticipantCount; /// Sticky multi-actor scoreboard (combat first; war/plot/family later). @@ -316,6 +320,9 @@ public sealed class InterestCandidate Novelty = Novelty, VisualConfidence = VisualConfidence, TotalScore = TotalScore, + NarrativeFunction = NarrativeFunction, + HasNarrativeFunction = HasNarrativeFunction, + NarrativeFunctionSource = NarrativeFunctionSource, ParticipantCount = ParticipantCount, NotableParticipantCount = NotableParticipantCount, Position = Position, diff --git a/IdleSpectator/InterestDirector.SwitchPolicy.cs b/IdleSpectator/InterestDirector.SwitchPolicy.cs index fa619cb..bc86bcc 100644 --- a/IdleSpectator/InterestDirector.SwitchPolicy.cs +++ b/IdleSpectator/InterestDirector.SwitchPolicy.cs @@ -19,6 +19,21 @@ public static partial class InterestDirector return false; } + if (candidate != _current && InterestVariety.IsEditorialReplayCooled(candidate)) + { + InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key); + return false; + } + + if (candidate != _current + && !StoryScheduler.MayUseCombatShot(candidate)) + { + InterestDropLog.Record( + "episode_combat_budget", + candidate.Label ?? candidate.Key); + return false; + } + if (_current == null) { return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); @@ -88,6 +103,29 @@ public static partial class InterestDirector return false; } + // Once an episode has a meaningful related development ready, let it reclaim the + // camera from ordinary unrelated material after a brief visual settling beat. + // Legitimate critical/payoff interruptions and live-combat holds remain protected. + if (StoryScheduler.ActiveEpisode != null + && onCurrent >= 0.75f) + { + NarrativeThread activeThread = + NarrativeStoryStore.Thread(StoryScheduler.ActiveEpisode.ThreadId); + NarrativeInterruptClass nextNarrativeClass = + InterruptPolicy.Classify(candidate, StoryScheduler.ActiveEpisode, activeThread); + NarrativeInterruptClass currentNarrativeClass = + InterruptPolicy.Classify(_current, StoryScheduler.ActiveEpisode, activeThread); + if (nextNarrativeClass == NarrativeInterruptClass.RelatedEscalation + && !InterruptPolicy.MayInterrupt(currentNarrativeClass) + && IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace)) + { + InterestDropLog.Record( + "episode_reclaim", + $"cur={_current.Key} next={candidate.Key}"); + return true; + } + } + bool inGrace = InQuietGrace; if (inGrace) { diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index f87bf0e..45cc283 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -654,6 +654,7 @@ public static partial class InterestDirector StoryPlanner.Tick(now); LifeSagaRoster.Tick(now); LifeSagaSession.EnsureBound(); + StoryScheduler.Tick(now); IdleHitchProbe.Mark("story_done"); if (ReadPauseActive) @@ -1950,6 +1951,7 @@ public static partial class InterestDirector _current = null; _harnessCombatForcedCold = false; _inactiveSince = -999f; + CameraDirector.ClearWatchReason(); EnsureIdleFocus(now); } @@ -2045,6 +2047,18 @@ public static partial class InterestDirector return null; } + InterestCandidate storyChoice = StoryScheduler.SelectForCamera(PendingScratch, _current, now); + if (storyChoice != null) + { + return storyChoice; + } + + if (StoryScheduler.ShouldHoldForEpisode(now)) + { + return null; + } + + // When no episode owns a usable development, select bounded ambient/world context. // EnrichTopK runs inside InterestVariety.Pick once - avoid a second top-K meta walk here. return InterestVariety.Pick(PendingScratch, _current); } @@ -2718,6 +2732,7 @@ public static partial class InterestDirector private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt) { + StoryScheduler.NoteActualSelection(next); if (next == null) { return; diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs index c6f2f86..6ae677d 100644 --- a/IdleSpectator/InterestScoring.cs +++ b/IdleSpectator/InterestScoring.cs @@ -159,7 +159,6 @@ public static class InterestScoring float repeatPenalty = InterestVariety.RepeatArcPenalty(c); float frequencyAdjust = InterestVariety.FrequencyAdjust(c); - float causalBonus = CausalHeat.ScoreBonus(c); float sagaBonus = LifeSagaRoster.ScoreBonus(c); float ownershipBonus = StoryPlanner.OwnershipBoost(c); c.TotalScore = c.EventStrength + scaleBonus @@ -168,7 +167,6 @@ public static class InterestScoring + c.Novelty * w.noveltyMultiplier - repeatPenalty + frequencyAdjust - + causalBonus + sagaBonus + ownershipBonus; string detail = @@ -185,11 +183,6 @@ public static class InterestScoring + frequencyAdjust.ToString("0.#"); } - if (causalBonus > 0.05f) - { - detail += $" causal=+{causalBonus:0.#}"; - } - if (sagaBonus > 0.05f) { detail += $" saga=+{sagaBonus:0.#}"; diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index de278f5..ef2d55b 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -16,6 +16,7 @@ public static class InterestVariety public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds; public static float FixedDwellBeatCooldownSeconds => Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds); + public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f); private static readonly List RecentMix = new List(32); private static readonly List RecentArcs = new List(32); @@ -163,10 +164,83 @@ public static class InterestVariety } } + // Sticky scenes are allowed to remain on screen while live, but once the editor + // leaves one it must not immediately re-announce the identical phase/card. + // The organic story-first soak otherwise produced Duel A-vs-B and "A is fighting" + // several times in succession, plus repeated unchanged family-pack cards. + string replayKey = EditorialReplayKey(chosen); + if (!string.IsNullOrEmpty(replayKey)) + { + StampCooldown( + replayKey, + Time.unscaledTime + EditorialReplayCooldownSeconds); + } + // Soft life chapters cool ledger heat so the same villagers do not monopolize. // Soft-life cool no longer demotes saga MCs (CharacterLedger removed). } + /// + /// True when the exact sticky editorial card was shown recently. This is deliberately + /// separate from moment-beat cooling: a live scene may continue, but cannot be selected + /// again as a fresh cut until another editorial beat has had room to breathe. + /// + public static bool IsEditorialReplayCooled(InterestCandidate c, float now = -1f) + { + string key = EditorialReplayKey(c); + if (string.IsNullOrEmpty(key)) + { + return false; + } + + if (now < 0f) + { + now = Time.unscaledTime; + } + + return CooldownUntil.TryGetValue(key, out float until) && now < until; + } + + public static bool HarnessProbeEditorialReplay(out string detail) + { + Clear(); + var first = new InterestCandidate + { + Key = "combat:pair:1:2", + Label = "Duel - Alpha vs Beta", + Completion = InterestCompletionKind.CombatActive + }; + NoteSelection(first, updateMix: false); + var replay = new InterestCandidate + { + Key = "combat:pair:1:2:refresh", + Label = "Duel - Alpha vs Beta", + Completion = InterestCompletionKind.CombatActive + }; + var different = new InterestCandidate + { + Key = "combat:pair:1:3", + Label = "Duel - Alpha vs Gamma", + Completion = InterestCompletionKind.CombatActive + }; + var texture = new InterestCandidate + { + Key = "status:sleeping:1", + Label = "Alpha · Falls asleep", + Completion = InterestCompletionKind.StatusPhase, + StatusId = "sleeping" + }; + bool sameCooled = IsEditorialReplayCooled(replay); + bool differentOpen = !IsEditorialReplayCooled(different); + bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture); + bool ok = sameCooled && differentOpen && textureUsesBeatPolicy; + detail = "sameCooled=" + sameCooled + + " differentOpen=" + differentOpen + + " textureUsesBeatPolicy=" + textureUsesBeatPolicy; + Clear(); + return ok; + } + /// /// Score to subtract when this candidate matches the last shown variety arc. /// After one duel show, the next duel pays repeatArcPenaltyPer; a lover resets the streak. @@ -637,6 +711,12 @@ public static class InterestVariety continue; } + if (IsEditorialReplayCooled(c, now)) + { + InterestDropLog.Record("editorial_replay", c.Label ?? c.Key); + continue; + } + if (c.LeadKind == InterestLeadKind.CharacterLed) { CharPool.Add(c); @@ -838,6 +918,35 @@ public static class InterestVariety } } + private static string EditorialReplayKey(InterestCandidate c) + { + if (c == null) + { + return ""; + } + + bool stickyCard = c.Completion == InterestCompletionKind.CombatActive + || c.Completion == InterestCompletionKind.WarFront + || c.Completion == InterestCompletionKind.PlotActive + || c.Completion == InterestCompletionKind.StatusOutbreak + || c.Completion == InterestCompletionKind.FamilyPack + || StoryReason.IsStoryAsset(c.AssetId) + || string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase); + if (!stickyCard) + { + return ""; + } + + string label = (c.Label ?? "").Trim().ToLowerInvariant(); + if (!string.IsNullOrEmpty(label)) + { + return "replay:label:" + label; + } + + string key = (c.Key ?? "").Trim().ToLowerInvariant(); + return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key; + } + /// /// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class. /// diff --git a/IdleSpectator/Narrative/EpisodeShotSelector.cs b/IdleSpectator/Narrative/EpisodeShotSelector.cs new file mode 100644 index 0000000..b37a574 --- /dev/null +++ b/IdleSpectator/Narrative/EpisodeShotSelector.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; + +namespace IdleSpectator; + +public sealed class EpisodeShotProposal +{ + public InterestCandidate Candidate; + public NarrativeInterruptClass InterruptClass; + public string Reason = ""; + public float EditorialScore; +} + +/// Selects the best presentable shot that can explain its place in the active episode. +public static class EpisodeShotSelector +{ + public static EpisodeShotProposal Pick( + IList candidates, + EpisodePlan episode, + NarrativeThread thread, + bool requirePresentable = true) + { + if (candidates == null || candidates.Count == 0 || episode == null) return null; + EpisodeShotProposal bestRelated = null; + EpisodeShotProposal bestInterrupt = null; + for (int i = 0; i < candidates.Count; i++) + { + InterestCandidate c = candidates[i]; + bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase); + if (c == null || c.Selected + || InterestVariety.IsEditorialReplayCooled(c) + || !StoryScheduler.MayUseCombatShot(episode, c) + || (requirePresentable && !harness && !EventPresentability.WouldShow(c))) continue; + NarrativeFunction function = NarrativeFunctionPolicy.Classify(c); + if (function == NarrativeFunction.Noise) continue; + NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread); + float score = EditorialScore(c, episode, kind, function); + var proposal = new EpisodeShotProposal + { + Candidate = c, + InterruptClass = kind, + EditorialScore = score, + Reason = Reason(kind) + }; + if (kind == NarrativeInterruptClass.RelatedEscalation) + { + if (bestRelated == null || score > bestRelated.EditorialScore) bestRelated = proposal; + } + else if (InterruptPolicy.MayInterrupt(kind) + && (bestInterrupt == null || score > bestInterrupt.EditorialScore)) + { + bestInterrupt = proposal; + } + } + + // A productive related development owns the episode. Critical/payoff material interrupts + // only when no related shot is ready, or when its editorial advantage is decisive. + if (bestRelated != null + && (bestInterrupt == null || bestInterrupt.EditorialScore < bestRelated.EditorialScore + 25f)) + { + return bestRelated; + } + + return bestInterrupt ?? bestRelated; + } + + private static float EditorialScore( + InterestCandidate c, + EpisodePlan episode, + NarrativeInterruptClass kind, + NarrativeFunction function) + { + float relation = c.SubjectId == episode.ProtagonistId ? 40f + : c.RelatedId == episode.ProtagonistId ? 30f : 0f; + float interrupt = kind == NarrativeInterruptClass.WorldCritical ? 80f + : kind == NarrativeInterruptClass.StoryPayoff ? 35f + : kind == NarrativeInterruptClass.RelatedEscalation ? 25f : 0f; + return relation + interrupt + NarrativeFunctionPolicy.EditorialBonus(function) + + c.EventStrength * 0.35f + + c.VisualConfidence * 10f + c.Novelty * 3f; + } + + private static string Reason(NarrativeInterruptClass kind) + { + switch (kind) + { + case NarrativeInterruptClass.RelatedEscalation: return "active_episode"; + case NarrativeInterruptClass.WorldCritical: return "world_critical_interrupt"; + case NarrativeInterruptClass.StoryPayoff: return "story_payoff_interrupt"; + default: return "not_episode_worthy"; + } + } +} diff --git a/IdleSpectator/Narrative/InterruptPolicy.cs b/IdleSpectator/Narrative/InterruptPolicy.cs new file mode 100644 index 0000000..e1c8b37 --- /dev/null +++ b/IdleSpectator/Narrative/InterruptPolicy.cs @@ -0,0 +1,94 @@ +using System; + +namespace IdleSpectator; + +public enum NarrativeInterruptClass +{ + None, + RelatedEscalation, + StoryPayoff, + WorldCritical, + OrdinaryInteresting, + TextureNoise +} + +/// Explicit editorial interrupt classification for episode scheduling. +public static class InterruptPolicy +{ + public static NarrativeInterruptClass Classify( + InterestCandidate candidate, + EpisodePlan episode, + NarrativeThread thread) + { + if (candidate == null) return NarrativeInterruptClass.None; + NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate); + if (function == NarrativeFunction.Noise || function == NarrativeFunction.CharacterTexture) + { + return NarrativeInterruptClass.TextureNoise; + } + if (IsRelated(candidate, episode, thread) + && NarrativeFunctionPolicy.ChangesStoryState(function)) + { + return NarrativeInterruptClass.RelatedEscalation; + } + if (IsWorldCritical(candidate)) return NarrativeInterruptClass.WorldCritical; + if (IsStoryPayoff(candidate)) return NarrativeInterruptClass.StoryPayoff; + + return NarrativeInterruptClass.OrdinaryInteresting; + } + + public static bool MayInterrupt(NarrativeInterruptClass kind) + { + return kind == NarrativeInterruptClass.WorldCritical + || kind == NarrativeInterruptClass.StoryPayoff + || kind == NarrativeInterruptClass.RelatedEscalation; + } + + public static bool IsRelated(InterestCandidate candidate, EpisodePlan episode, NarrativeThread thread) + { + if (candidate == null || episode == null) return false; + if (Touches(candidate, episode.ProtagonistId)) return true; + for (int i = 0; i < episode.EligibleCastIds.Count; i++) + { + if (Touches(candidate, episode.EligibleCastIds[i])) return true; + } + + if (thread == null || string.IsNullOrEmpty(thread.AnchorKey)) return false; + return EqualsKey(candidate.CorrelationKey, thread.AnchorKey) + || EqualsKey(candidate.CityKey, thread.AnchorKey) + || EqualsKey(candidate.KingdomKey, thread.AnchorKey) + || (candidate.Key ?? "").IndexOf(thread.AnchorKey, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool Touches(InterestCandidate c, long id) + { + if (id == 0) return false; + if (c.SubjectId == id || c.RelatedId == id || c.PairOwnerId == id + || c.PairPartnerId == id || c.TheaterLeadId == id + || EventFeedUtil.SafeId(c.FollowUnit) == id) return true; + for (int i = 0; i < c.ParticipantIds.Count; i++) + { + if (c.ParticipantIds[i] == id) return true; + } + + return false; + } + + private static bool IsWorldCritical(InterestCandidate c) + { + if (c.EventStrength >= 95f) return true; + return c.Completion == InterestCompletionKind.EarthquakeActive + || c.Completion == InterestCompletionKind.StatusOutbreak + || (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool IsStoryPayoff(InterestCandidate c) + { + return StoryReason.IsStoryAsset(c.AssetId) + || (c.Source ?? "").IndexOf("story", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool EqualsKey(string a, string b) => + !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b) + && string.Equals(a, b, StringComparison.OrdinalIgnoreCase); +} diff --git a/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs b/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs new file mode 100644 index 0000000..3582b81 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs @@ -0,0 +1,162 @@ +using UnityEngine; + +namespace IdleSpectator; + +/// Thin adapters from confirmed game observations to semantic narrative developments. +public static class NarrativeDevelopmentRouter +{ + public static bool BondFormed(Actor subject, Actor partner, string source) + { + return Record(NarrativeDevelopmentKind.BondFormed, NarrativeFunction.ThreadOpener, + subject, partner, "bond:" + Pair(subject, partner) + ":formed:" + TimeBucket(), source, magnitude: 72f); + } + + public static bool BondBroken(Actor subject, Actor former, string source) + { + return Record(NarrativeDevelopmentKind.BondBroken, NarrativeFunction.TurningPoint, + subject, former, "bond:" + Pair(subject, former) + ":broken:" + TimeBucket(), source, magnitude: 88f, + outcome: "partnership ended"); + } + + public static bool ChildBorn(Actor parent, Actor child, string source) + { + string family = FamilyKey(parent); + return Record(NarrativeDevelopmentKind.ChildBorn, NarrativeFunction.Consequence, + parent, child, "child:" + EventFeedUtil.SafeId(parent) + ":" + EventFeedUtil.SafeId(child), + 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); + } + + public static bool Death(Actor victim, Actor killer, string attackType, string source) + { + return Record(NarrativeDevelopmentKind.Death, NarrativeFunction.Resolution, + victim, killer, "death:" + EventFeedUtil.SafeId(victim), source, + newState: "dead", outcome: attackType ?? "", magnitude: 100f); + } + + public static bool Kill(Actor killer, Actor victim, string source) + { + return Record(NarrativeDevelopmentKind.Kill, NarrativeFunction.TurningPoint, + killer, victim, "kill:" + EventFeedUtil.SafeId(killer) + ":" + EventFeedUtil.SafeId(victim), + source, outcome: "victim died", magnitude: 82f); + } + + public static bool Role(Actor subject, string role, bool gained, string source) + { + return Record(gained ? NarrativeDevelopmentKind.RoleGained : NarrativeDevelopmentKind.RoleLost, + gained ? NarrativeFunction.TurningPoint : NarrativeFunction.Consequence, + subject, null, "role:" + EventFeedUtil.SafeId(subject) + ":" + (role ?? ""), source, + newState: role, magnitude: 78f); + } + + public static bool Founding(Actor subject, string kind, string key, string source) + { + return Record(NarrativeDevelopmentKind.HomeFounded, NarrativeFunction.TurningPoint, + subject, null, "founding:" + kind + ":" + key + ":" + EventFeedUtil.SafeId(subject), 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 Record(ended ? NarrativeDevelopmentKind.WarEnded : NarrativeDevelopmentKind.WarJoined, + ended ? NarrativeFunction.Resolution : NarrativeFunction.Escalation, + subject, other, "war:" + warKey + ":" + EventFeedUtil.SafeId(subject) + ":" + ended, + 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 Record(ended ? NarrativeDevelopmentKind.PlotEnded : NarrativeDevelopmentKind.PlotJoined, + ended ? NarrativeFunction.Resolution : NarrativeFunction.Escalation, + subject, other, "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject) + ":" + ended, + source, plotKey: plotKey, magnitude: ended ? 78f : 64f, + outcome: ended ? "plot ended" : ""); + } + + private static bool Record( + NarrativeDevelopmentKind kind, + NarrativeFunction function, + Actor subject, + Actor other, + string id, + string source, + string familyKey = "", + string cityKey = "", + string kingdomKey = "", + string warKey = "", + string plotKey = "", + string newState = "", + string outcome = "", + float magnitude = 50f) + { + long subjectId = EventFeedUtil.SafeId(subject); + if (subjectId == 0) return false; + long otherId = EventFeedUtil.SafeId(other); + return NarrativeStoryStore.Record(new NarrativeDevelopment + { + Id = id ?? "", + Kind = kind, + Function = function, + SubjectId = subjectId, + Subject = LifeSagaMemory.Snapshot(subject), + OtherId = otherId, + Other = LifeSagaMemory.Snapshot(other), + CorrelationKey = id ?? "", + FamilyKey = familyKey ?? "", + CityKey = cityKey ?? "", + KingdomKey = kingdomKey ?? "", + WarKey = warKey ?? "", + PlotKey = plotKey ?? "", + NewState = newState ?? "", + Outcome = outcome ?? "", + EvidenceSource = source ?? "", + DateLabel = LatestDate(subjectId), + OccurredAt = Time.unscaledTime, + Magnitude = magnitude, + Confidence = 1f, + Presentable = true, + IsNewStateChange = true + }); + } + + private static string LatestDate(long subjectId) + { + var facts = LifeSagaMemory.FactsFor(subjectId); + if (facts != null && facts.Count > 0 && facts[0] != null) return facts[0].DateLabel ?? ""; + return ""; + } + + private static string Pair(Actor a, Actor b) + { + long ai = EventFeedUtil.SafeId(a); + long bi = EventFeedUtil.SafeId(b); + if (bi == 0) return ai.ToString(); + return ai <= bi ? ai + ":" + bi : bi + ":" + ai; + } + + private static string FamilyKey(Actor actor) + { + try + { + if (actor != null && actor.hasFamily() && actor.family != null) + { + return actor.family.getID().ToString(); + } + } + catch + { + // Subject id remains a stable lineage fallback. + } + + return EventFeedUtil.SafeId(actor).ToString(); + } + + private static int TimeBucket() => Mathf.FloorToInt(Time.unscaledTime * 10f); +} diff --git a/IdleSpectator/Narrative/NarrativeFunctionCatalog.cs b/IdleSpectator/Narrative/NarrativeFunctionCatalog.cs new file mode 100644 index 0000000..040c149 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeFunctionCatalog.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// +/// Explicit catalog-to-editorial-role mapping at the single candidate intake boundary. +/// Structural fallbacks cover live theaters; string heuristics remain only a compatibility fallback. +/// +public static class NarrativeFunctionCatalog +{ + private static readonly Dictionary Exact = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly HashSet ObservedKeys = new HashSet(StringComparer.Ordinal); + private static readonly int[] FunctionCounts = new int[9]; + + public static int ObservedCount { get; private set; } + public static int ExplicitCount { get; private set; } + public static int StructuralCount { get; private set; } + public static int FallbackCount { get; private set; } + + static NarrativeFunctionCatalog() + { + Add(NarrativeFunction.ThreadOpener, + "set_lover", "fallen_in_love", "fell_in_love", "new_family", "family_group_new", + "just_made_friend"); + Add(NarrativeFunction.Development, + "live_family", "family_group_join", "just_kissed", "alliance_join", "alliance_create", + "alliance_new", "just_gave_gift", "just_received_gift", "just_talked_gossip"); + Add(NarrativeFunction.Escalation, + "live_combat", "live_battle", "live_war", "live_plot", "live_outbreak", + "new_war", "diplomacy_war_started", "just_started_war", "total_war_started", + "whisper_of_war", "rebellion", "cause_rebellion", "just_rebelled", "just_injured", + "just_cursed", "just_possessed", "status_soul_harvested", "attacker_stop_war"); + Add(NarrativeFunction.TurningPoint, + "clear_lover", "just_killed", "milestone_kill", "become_alpha", "become_king", + "become_leader", "king_new", "kingdom_new", "city_new", "just_found_house", + "conquered_city", "was_conquered", "clan_ascension", "kingdom_royal_clan_changed", + "kingdom_royal_clan_new", "culture_divide", "religion_schism", "language_divergence"); + Add(NarrativeFunction.Resolution, + "diplomacy_war_ended", "just_made_peace", "just_finished_plot", "just_won_war", + "aftermath_war_linger", "aftermath_plot_fallout", "recovery_outbreak"); + Add(NarrativeFunction.Consequence, + "add_child", "just_had_child", "baby_created", "just_born", "just_got_out_of_egg", + "just_became_adult", + "death_best_friend", "death_child", "death_family_member", "death_lover", "king_dead", + "king_killed", "favorite_dead", "favorite_killed", "city_destroyed", "destroyed_city", + "kingdom_destroyed", "kingdom_fell_apart", "kingdom_fractured", "kingdom_shattered", + "just_lost_house", "just_lost_war", "lost_capital", "lost_city", "lost_crown", + "razed_capital", "razed_city", "family_removed", "alliance_destroy", "alliance_dissolved", + "aftermath_love_linger", "aftermath_mourner", "aftermath_survivor", "epilogue_crisis", + "epilogue_related", "wrote_book", "new_book"); + Add(NarrativeFunction.WorldContext, + "earthquake", "small_earthquake", "small_meteorite", "alien_invasion", "ash_bandits", + "biomass", "dragon_from_farlands", "garden_surprise", "greg_abominations", "heatwave", + "hellspawn", "ice_ones_awoken", "mad_thoughts", "sudden_snowman", "tornado", "tumor", + "underground_necromancer", "new_culture", "new_language", "new_religion"); + Add(NarrativeFunction.CharacterTexture, + "find_lover", "sexual_reproduction_try", "family_group_follow", "family_group_leave", + "just_ate", "just_cried", "just_laughed", "just_played", "just_pooped", "just_sang", + "just_slept", "slept_outside", "just_talked", "paid_tax", "had_bad_dream", + "had_good_dream", "had_nightmare", "just_surprised", "just_swore", "just_read_book"); + } + + public static void ClearCoverage() + { + ObservedKeys.Clear(); + Array.Clear(FunctionCounts, 0, FunctionCounts.Length); + ObservedCount = 0; + ExplicitCount = 0; + StructuralCount = 0; + FallbackCount = 0; + } + + public static void Stamp(InterestCandidate candidate) + { + if (candidate == null) return; + if (!candidate.HasNarrativeFunction) + { + if (TryExact(candidate.HappinessEffectId, out NarrativeFunction function) + || TryExact(candidate.AssetId, out function) + || TryExact(candidate.StatusId, out function) + || TryExact(candidate.Verb, out function)) + { + Set(candidate, function, "catalog"); + } + else if (TryDomain(candidate, out function)) + { + Set(candidate, function, "structural"); + } + else + { + // Compatibility fallback is stamped once, so downstream policy is deterministic. + Set(candidate, NarrativeFunctionPolicy.Classify(candidate), "fallback"); + } + } + + NoteCoverage(candidate); + } + + public static string CoverageSummary => "observed=" + ObservedCount + + " explicit=" + ExplicitCount + " structural=" + StructuralCount + + " fallback=" + FallbackCount + " functions=" + FormatCounts(); + + private static bool TryDomain(InterestCandidate c, out NarrativeFunction function) + { + string source = c.Source ?? ""; + string category = c.Category ?? ""; + if (c.Completion == InterestCompletionKind.CombatActive + || c.Completion == InterestCompletionKind.WarFront + || c.Completion == InterestCompletionKind.PlotActive + || c.Completion == InterestCompletionKind.StatusOutbreak) + { + function = NarrativeFunction.Escalation; + return true; + } + if (c.Completion == InterestCompletionKind.HappinessGrief) + { + function = NarrativeFunction.TurningPoint; + return true; + } + if (c.Completion == InterestCompletionKind.EarthquakeActive + || Contains(source, "disaster") || Contains(category, "disaster") || Contains(source, "era")) + { + function = NarrativeFunction.WorldContext; + return true; + } + if (Contains(source, "plot") || Contains(category, "plot")) + { + function = NarrativeFunction.Development; + return true; + } + if (Contains(source, "book") || Contains(source, "trait") + || c.Completion == InterestCompletionKind.CharacterVignette + || c.Completion == InterestCompletionKind.StatusPhase) + { + function = NarrativeFunction.CharacterTexture; + return true; + } + + function = NarrativeFunction.Noise; + return false; + } + + private static void Set(InterestCandidate c, NarrativeFunction function, string source) + { + c.NarrativeFunction = function; + c.HasNarrativeFunction = true; + c.NarrativeFunctionSource = source; + } + + private static void NoteCoverage(InterestCandidate c) + { + string key = c.Key ?? ""; + if (!ObservedKeys.Add(key)) return; + ObservedCount++; + int index = (int)c.NarrativeFunction; + if (index >= 0 && index < FunctionCounts.Length) FunctionCounts[index]++; + if (c.NarrativeFunctionSource == "catalog") ExplicitCount++; + else if (c.NarrativeFunctionSource == "structural") StructuralCount++; + else FallbackCount++; + } + + private static bool TryExact(string id, out NarrativeFunction function) + { + function = NarrativeFunction.Noise; + if (string.IsNullOrEmpty(id)) return false; + string key = id.Trim(); + if (Exact.TryGetValue(key, out function)) return true; + if (key.StartsWith("disaster_", StringComparison.OrdinalIgnoreCase)) + { + function = NarrativeFunction.WorldContext; + return true; + } + if (key.StartsWith("summon_", StringComparison.OrdinalIgnoreCase)) + { + function = NarrativeFunction.Escalation; + return true; + } + if (key.StartsWith("asexual_reproduction_", StringComparison.OrdinalIgnoreCase)) + { + function = NarrativeFunction.CharacterTexture; + return true; + } + return false; + } + + private static void Add(NarrativeFunction function, params string[] ids) + { + for (int i = 0; i < ids.Length; i++) Exact[ids[i]] = function; + } + + private static bool Contains(string text, string needle) => + text.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0; + + private static string FormatCounts() + { + var parts = new List(FunctionCounts.Length); + for (int i = 0; i < FunctionCounts.Length; i++) + { + if (FunctionCounts[i] > 0) parts.Add(((NarrativeFunction)i) + ":" + FunctionCounts[i]); + } + return string.Join(",", parts.ToArray()); + } +} diff --git a/IdleSpectator/Narrative/NarrativeFunctionPolicy.cs b/IdleSpectator/Narrative/NarrativeFunctionPolicy.cs new file mode 100644 index 0000000..b704bf0 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeFunctionPolicy.cs @@ -0,0 +1,110 @@ +using System; + +namespace IdleSpectator; + +/// +/// Editorial meaning of a camera candidate. This is deliberately separate from numeric +/// spectacle strength: a loud event can still be context, while a quiet state change can +/// advance a life story. +/// +public static class NarrativeFunctionPolicy +{ + public static NarrativeFunction Classify(InterestCandidate candidate) + { + if (candidate == null) return NarrativeFunction.Noise; + if (candidate.HasNarrativeFunction) return candidate.NarrativeFunction; + + string asset = (candidate.AssetId ?? "").ToLowerInvariant(); + string category = (candidate.Category ?? "").ToLowerInvariant(); + string source = (candidate.Source ?? "").ToLowerInvariant(); + string verb = (candidate.Verb ?? "").ToLowerInvariant(); + string text = asset + " " + category + " " + source + " " + verb; + + if (ContainsAny(text, "add_child", "child_born", "new_child", "became_parent")) + return NarrativeFunction.Consequence; + if (ContainsAny(text, "clear_lover", "lover_died", "partner_died", "grief")) + return NarrativeFunction.TurningPoint; + if (ContainsAny(text, "set_lover", "new_family", "married", "bond_formed")) + return NarrativeFunction.ThreadOpener; + if (ContainsAny(text, "war_ended", "plot_ended", "plot_resolved", "recovered")) + return NarrativeFunction.Resolution; + if (ContainsAny(text, "death", "died", "killed", "destroyed", "role_lost", "lost_home")) + return NarrativeFunction.Consequence; + if (ContainsAny(text, "become_king", "new_king", "founded", "new_city", "role_gained")) + return NarrativeFunction.TurningPoint; + if (ContainsAny(text, "war_started", "joined_war", "plot_joined", "betray", "rival")) + return NarrativeFunction.Escalation; + + switch (candidate.Completion) + { + case InterestCompletionKind.CombatActive: + case InterestCompletionKind.WarFront: + case InterestCompletionKind.PlotActive: + case InterestCompletionKind.StatusOutbreak: + return NarrativeFunction.Escalation; + case InterestCompletionKind.HappinessGrief: + return NarrativeFunction.TurningPoint; + case InterestCompletionKind.EarthquakeActive: + return NarrativeFunction.WorldContext; + case InterestCompletionKind.FamilyPack: + return NarrativeFunction.Development; + case InterestCompletionKind.ActivityActive: + return candidate.LeadKind == InterestLeadKind.EventLed + ? NarrativeFunction.Development + : NarrativeFunction.CharacterTexture; + case InterestCompletionKind.CharacterVignette: + case InterestCompletionKind.StatusPhase: + return NarrativeFunction.CharacterTexture; + } + + if (StoryReason.IsStoryAsset(candidate.AssetId) + || source.IndexOf("story", StringComparison.Ordinal) >= 0) + { + return NarrativeFunction.Development; + } + + if (candidate.LeadKind == InterestLeadKind.CharacterLed) + return NarrativeFunction.CharacterTexture; + if (candidate.EventStrength >= 95f) return NarrativeFunction.WorldContext; + if (candidate.EventStrength >= 70f) return NarrativeFunction.Development; + return candidate.EventStrength < 35f + ? NarrativeFunction.Noise + : NarrativeFunction.CharacterTexture; + } + + public static bool ChangesStoryState(NarrativeFunction function) + { + return function == NarrativeFunction.ThreadOpener + || function == NarrativeFunction.Development + || function == NarrativeFunction.Escalation + || function == NarrativeFunction.TurningPoint + || function == NarrativeFunction.Resolution + || function == NarrativeFunction.Consequence; + } + + public static float EditorialBonus(NarrativeFunction function) + { + switch (function) + { + case NarrativeFunction.ThreadOpener: return 8f; + case NarrativeFunction.Development: return 12f; + case NarrativeFunction.Escalation: return 18f; + case NarrativeFunction.TurningPoint: return 28f; + case NarrativeFunction.Resolution: return 32f; + case NarrativeFunction.Consequence: return 24f; + case NarrativeFunction.WorldContext: return 10f; + case NarrativeFunction.CharacterTexture: return -15f; + default: return -35f; + } + } + + private static bool ContainsAny(string text, params string[] needles) + { + for (int i = 0; i < needles.Length; i++) + { + if (text.IndexOf(needles[i], StringComparison.Ordinal) >= 0) return true; + } + + return false; + } +} diff --git a/IdleSpectator/Narrative/NarrativeModels.cs b/IdleSpectator/Narrative/NarrativeModels.cs new file mode 100644 index 0000000..98316e7 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeModels.cs @@ -0,0 +1,262 @@ +using System.Collections.Generic; + +namespace IdleSpectator; + +public enum NarrativeDevelopmentKind +{ + Unknown, + BondFormed, + BondBroken, + ChildBorn, + FriendFormed, + Death, + Kill, + RoleGained, + RoleLost, + HomeFounded, + HomeGained, + HomeLost, + WarJoined, + WarEnded, + PlotJoined, + PlotEnded, + RivalEncounter, + Transformation, + Recovery +} + +public enum NarrativeFunction +{ + Noise, + CharacterTexture, + WorldContext, + ThreadOpener, + Development, + Escalation, + TurningPoint, + Resolution, + Consequence +} + +public enum NarrativeThreadKind +{ + Unknown, + Courtship, + Partnership, + Lineage, + SeparationGrief, + RiseToRule, + Reign, + Succession, + Founding, + HomeLoss, + Rivalry, + PersonalCombat, + WarInvolvement, + PlotBetrayal, + Transformation, + Recovery +} + +public enum NarrativePhase +{ + Setup, + Pressure, + Escalation, + TurningPoint, + Outcome, + Consequence +} + +public enum NarrativeThreadStatus +{ + Emerging, + Active, + Dormant, + Resolved, + Abandoned +} + +public enum CharacterConsequenceKind +{ + Unknown, + FoundLove, + LostPartner, + BecameParent, + WasBorn, + LostFamily, + LostLife, + TookLife, + RoseToRole, + LostRole, + FoundedHome, + LostHome, + EnteredWar, + SurvivedWar, + JoinedPlot, + PlotResolved, + Transformed, + Recovered +} + +/// Evidence-backed semantic state transition. Contains no final player-facing prose. +public sealed class NarrativeDevelopment +{ + public string Id = ""; + public NarrativeDevelopmentKind Kind; + public NarrativeFunction Function; + public long SubjectId; + public LifeSagaIdentity Subject; + public long OtherId; + public LifeSagaIdentity Other; + public readonly List OtherIds = new List(4); + 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 = 1f; + public bool Presentable = true; + public bool IsNewStateChange = true; +} + +public sealed class NarrativeCastRole +{ + public long CharacterId; + public string Role = ""; +} + +/// Durable unresolved question centered on one life. +public sealed class NarrativeThread +{ + public string Id = ""; + public NarrativeThreadKind Kind; + public long ProtagonistId; + public readonly List Cast = new List(6); + public readonly List DevelopmentIds = new List(12); + public string AnchorKey = ""; + public string OpenedByDevelopmentId = ""; + public string LatestMeaningfulChangeId = ""; + public string CentralQuestion = ""; + public string PressureEvidence = ""; + public string Resolution = ""; + public NarrativePhase Phase; + public NarrativeThreadStatus Status; + public float OpenedAt; + public float UpdatedAt; + public float Momentum; + public float Urgency; + public float Coherence; + public float Novelty; + public float CoverageDebt; + + public bool IsOpen => Status == NarrativeThreadStatus.Emerging + || Status == NarrativeThreadStatus.Active + || Status == NarrativeThreadStatus.Dormant; + + public bool HasCast(long id) + { + if (id == 0) return false; + for (int i = 0; i < Cast.Count; i++) + { + if (Cast[i] != null && Cast[i].CharacterId == id) return true; + } + + return false; + } + + public void AddCast(long id, string role) + { + if (id == 0 || HasCast(id)) return; + Cast.Add(new NarrativeCastRole { CharacterId = id, Role = role ?? "" }); + } +} + +public sealed class CharacterConsequence +{ + public string Id = ""; + public long CharacterId; + public string ThreadId = ""; + public string DevelopmentId = ""; + public CharacterConsequenceKind Kind; + public string Role = ""; + public long OtherId; + public LifeSagaIdentity Other; + public string Context = ""; + public string Outcome = ""; + public string DateLabel = ""; + public float OccurredAt; + public float Importance; + public float Confidence = 1f; +} + +public sealed class CharacterStoryState +{ + public long CharacterId; + public LifeSagaIdentity Identity; + public readonly List OpenThreadIds = new List(6); + public readonly List ResolvedThreadIds = new List(8); + public readonly List RecentDevelopmentIds = new List(16); + public readonly List ConsequenceIds = new List(16); + public float StoryMomentum; + public float StoryPotential; + public float LastMeaningfulChangeAt; +} + +public sealed class NarrativeChapter +{ + public string Id = ""; + public string ThreadId = ""; + public string Line = ""; + public string DateLabel = ""; + public float At; + public float Importance; +} + +public enum EpisodePurpose +{ + Establish, + Develop, + Payoff, + Consequence, + Reintroduce +} + +public sealed class EpisodePlan +{ + public string ThreadId = ""; + public long ProtagonistId; + public readonly List EligibleCastIds = new List(6); + public string EntryDevelopmentId = ""; + public EpisodePurpose Purpose; + public string InformationGoal = ""; + public float StartedAt; + public float LastProgressAt; + public float InterruptedAt = -1f; + public string LastShotKey = ""; + public int CombatShotCount; + public bool GoalMet; +} + +public sealed class NarrativeBeatModel +{ + public long PerspectiveCharacterId; + public string DevelopmentId = ""; + public string ThreadId = ""; + public NarrativeDevelopmentKind ActionKind; + public long OtherId; + public LifeSagaIdentity Other; + public string Change = ""; + public string Outcome = ""; + public NarrativePhase ThreadPhase; + public string ContextEvidence = ""; + public float Confidence = 1f; +} diff --git a/IdleSpectator/Narrative/NarrativeProse.cs b/IdleSpectator/Narrative/NarrativeProse.cs new file mode 100644 index 0000000..fc00864 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeProse.cs @@ -0,0 +1,199 @@ +using System; + +namespace IdleSpectator; + +/// Evidence-backed renderer for structured narrative beats and consequences. +public static class NarrativeProse +{ + public static bool TryBuildBeat( + Actor focus, + InterestCandidate tip, + out NarrativeBeatModel model, + out string beat, + out string context) + { + model = null; + beat = ""; + context = ""; + 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 + { + 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: + { + 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; + } + 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 ""; + } + } + + public static string ContextLine(NarrativeBeatModel model, NarrativeThread thread) + { + if (model == null || thread == null) return ""; + // Only show concrete pressure. A generic central question is useful scheduler state, + // but is not evidence suitable for player-facing narration. + if (!string.IsNullOrEmpty(thread.PressureEvidence)) return SagaProse.Sentence(thread.PressureEvidence); + return ""; + } + + public static string ConsequenceLine(CharacterConsequence consequence) + { + if (consequence == null || consequence.Confidence < 0.75f) return ""; + string other = consequence.Other.Name ?? ""; + switch (consequence.Kind) + { + case CharacterConsequenceKind.FoundLove: + return string.IsNullOrEmpty(other) ? "Found love" : "Found love with " + other; + case CharacterConsequenceKind.LostPartner: + return string.IsNullOrEmpty(other) ? "Lost a partner" : "Lost " + other; + case CharacterConsequenceKind.BecameParent: + return string.IsNullOrEmpty(other) ? "Had a child" : "Welcomed child " + other; + case CharacterConsequenceKind.WasBorn: + return string.IsNullOrEmpty(other) ? "Was born" : "Was born to " + other; + case CharacterConsequenceKind.LostLife: + return string.IsNullOrEmpty(other) ? "Died" : "Was slain by " + other; + case CharacterConsequenceKind.TookLife: + return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other; + case CharacterConsequenceKind.RoseToRole: + return "Rose in standing"; + case CharacterConsequenceKind.LostRole: + return "Lost their former standing"; + case CharacterConsequenceKind.FoundedHome: + return "Founded a new home"; + case CharacterConsequenceKind.LostHome: + return "Lost their home"; + case CharacterConsequenceKind.EnteredWar: + return "Entered a war"; + case CharacterConsequenceKind.SurvivedWar: + return "Survived the war"; + case CharacterConsequenceKind.JoinedPlot: + return "Joined a plot"; + case CharacterConsequenceKind.PlotResolved: + return "Saw a plot resolved"; + case CharacterConsequenceKind.Transformed: + return "Was transformed"; + case CharacterConsequenceKind.Recovered: + return "Recovered"; + default: + return ""; + } + } + + public static string TryMergeChapter(string current, CharacterConsequence next) + { + if (string.IsNullOrEmpty(current) || next == null) return ""; + // Partnership chapters keep formation and loss separate because both are identity-changing. + // Lineage repeats collapse naturally in the presentation layer's family summary. + 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); + if (state == null) return null; + for (int i = 0; i < state.OpenThreadIds.Count; i++) + { + NarrativeThread t = NarrativeStoryStore.Thread(state.OpenThreadIds[i]); + if (t != null && t.DevelopmentIds.Contains(developmentId)) return t; + } + + for (int i = 0; i < state.ResolvedThreadIds.Count; i++) + { + NarrativeThread t = NarrativeStoryStore.Thread(state.ResolvedThreadIds[i]); + if (t != null && t.DevelopmentIds.Contains(developmentId)) return t; + } + + 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 new file mode 100644 index 0000000..7ff2b05 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeStoryStore.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// World-session narrative state. Updated from developments; independent of Saga roster churn. +public static class NarrativeStoryStore +{ + private const int RecentPerCharacter = 16; + private const int ConsequencesPerCharacter = 24; + private static readonly Dictionary Developments = + new Dictionary(StringComparer.Ordinal); + private static readonly Dictionary Characters = + new Dictionary(); + private static readonly Dictionary Threads = + new Dictionary(StringComparer.Ordinal); + private static readonly Dictionary Consequences = + new Dictionary(StringComparer.Ordinal); + private static readonly List ThreadScratch = new List(32); + + public static int Revision { get; private set; } + public static int DevelopmentCount => Developments.Count; + public static int ThreadCount => Threads.Count; + public static int CharacterCount => Characters.Count; + + public static void Clear() + { + Developments.Clear(); + Characters.Clear(); + Threads.Clear(); + Consequences.Clear(); + ThreadScratch.Clear(); + Revision++; + StoryScheduler.Clear(); + NarrativeTelemetry.Clear(); + } + + public static CharacterStoryState Character(long id) + { + Characters.TryGetValue(id, out CharacterStoryState state); + return state; + } + + public static NarrativeDevelopment Development(string id) + { + if (string.IsNullOrEmpty(id)) return null; + Developments.TryGetValue(id, out NarrativeDevelopment development); + return development; + } + + public static NarrativeThread Thread(string id) + { + if (string.IsNullOrEmpty(id)) return null; + Threads.TryGetValue(id, out NarrativeThread thread); + return thread; + } + + public static CharacterConsequence Consequence(string id) + { + if (string.IsNullOrEmpty(id)) return null; + Consequences.TryGetValue(id, out CharacterConsequence consequence); + return consequence; + } + + public static bool Record(NarrativeDevelopment development) + { + if (development == null || development.SubjectId == 0 || string.IsNullOrEmpty(development.Id)) + { + return false; + } + + if (Developments.TryGetValue(development.Id, out NarrativeDevelopment existing)) + { + existing.OccurredAt = Mathf.Max(existing.OccurredAt, development.OccurredAt); + existing.Magnitude = Mathf.Max(existing.Magnitude, development.Magnitude); + existing.Confidence = Mathf.Max(existing.Confidence, development.Confidence); + existing.IsNewStateChange = false; + return false; + } + + Developments[development.Id] = development; + CharacterStoryState subject = GetOrCreateCharacter(development.SubjectId, development.Subject); + TouchDevelopment(subject, development); + if (development.OtherId != 0) + { + GetOrCreateCharacter(development.OtherId, development.Other); + } + + NarrativeThreadRules.Apply(development); + Revision++; + return true; + } + + public static NarrativeThread OpenOrUpdateThread( + string id, + NarrativeThreadKind kind, + long protagonistId, + string anchor, + NarrativeDevelopment development, + string centralQuestion, + NarrativePhase phase, + NarrativeThreadStatus status) + { + if (string.IsNullOrEmpty(id) || protagonistId == 0 || development == null) return null; + bool created = !Threads.TryGetValue(id, out NarrativeThread thread); + if (created) + { + thread = new NarrativeThread + { + Id = id, + Kind = kind, + ProtagonistId = protagonistId, + AnchorKey = anchor ?? "", + OpenedByDevelopmentId = development.Id, + OpenedAt = development.OccurredAt, + Coherence = 1f, + Novelty = 1f + }; + Threads[id] = thread; + } + + thread.Kind = kind; + thread.CentralQuestion = centralQuestion ?? thread.CentralQuestion; + thread.Phase = phase; + thread.Status = status; + thread.UpdatedAt = development.OccurredAt; + thread.LatestMeaningfulChangeId = development.Id; + if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id); + thread.AddCast(protagonistId, "protagonist"); + if (development.OtherId != 0) thread.AddCast(development.OtherId, CastRole(development.Kind)); + thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function)); + thread.Urgency = Mathf.Max(thread.Urgency, UrgencyFor(development)); + + CharacterStoryState state = GetOrCreateCharacter(protagonistId, development.Subject); + MoveThreadReference(state, thread); + RecalculatePotential(state); + Revision++; + return thread; + } + + public static void AddConsequence(CharacterConsequence consequence) + { + if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return; + if (Consequences.ContainsKey(consequence.Id)) return; + Consequences[consequence.Id] = consequence; + CharacterStoryState state = GetOrCreateCharacter(consequence.CharacterId, default); + state.ConsequenceIds.Insert(0, consequence.Id); + while (state.ConsequenceIds.Count > ConsequencesPerCharacter) + { + state.ConsequenceIds.RemoveAt(state.ConsequenceIds.Count - 1); + } + + RecalculatePotential(state); + Revision++; + } + + public static void CopyOpenThreads(List into) + { + if (into == null) return; + into.Clear(); + foreach (NarrativeThread thread in Threads.Values) + { + if (thread != null + && (thread.IsOpen + || (thread.Status == NarrativeThreadStatus.Resolved + && Time.unscaledTime - thread.UpdatedAt < 20f))) + { + into.Add(thread); + } + } + } + + /// Copies bounded story-potential challengers without scanning the world. + public static void CopyEmergingCharacters(List into, float minimumPotential, int max) + { + if (into == null) return; + into.Clear(); + float now = Time.unscaledTime; + foreach (CharacterStoryState state in Characters.Values) + { + if (state != null && state.CharacterId != 0 + && EffectiveStoryPotential(state, now) >= minimumPotential) + { + into.Add(state); + } + } + + into.Sort((a, b) => EffectiveStoryPotential(b, now).CompareTo(EffectiveStoryPotential(a, now))); + if (max > 0 && into.Count > max) into.RemoveRange(max, into.Count - max); + } + + public static float EffectiveStoryPotential(CharacterStoryState state, float now = -1f) + { + if (state == null) return 0f; + if (now < 0f) now = Time.unscaledTime; + float age = Mathf.Max(0f, now - state.LastMeaningfulChangeAt); + // Narrative momentum has a long half-life, but is not a permanent celebrity badge. + float freshness = Mathf.Pow(0.5f, age / 300f); + return state.StoryPotential * freshness; + } + + public static NarrativeDevelopment LatestFor(long characterId, NarrativeDevelopmentKind kind, float maxAge) + { + CharacterStoryState state = Character(characterId); + if (state == null) return null; + float now = Time.unscaledTime; + for (int i = 0; i < state.RecentDevelopmentIds.Count; i++) + { + NarrativeDevelopment development = Development(state.RecentDevelopmentIds[i]); + if (development == null || development.Kind != kind) continue; + if (maxAge > 0f && now - development.OccurredAt > maxAge) continue; + return development; + } + + return null; + } + + public static List ChaptersFor(long characterId, int max) + { + var chapters = new List(Mathf.Max(0, max)); + CharacterStoryState state = Character(characterId); + if (state == null || max <= 0) return chapters; + var seenThreads = new HashSet(StringComparer.Ordinal); + for (int i = 0; i < state.ConsequenceIds.Count && chapters.Count < max; i++) + { + CharacterConsequence consequence = Consequence(state.ConsequenceIds[i]); + if (consequence == null || consequence.Confidence < 0.75f) continue; + string line; + if (consequence.Kind == CharacterConsequenceKind.BecameParent) + { + int children = CountConsequences(characterId, CharacterConsequenceKind.BecameParent); + line = children >= 3 ? "Raised a lineage of " + children + " children" + : NarrativeProse.ConsequenceLine(consequence); + } + else + { + line = NarrativeProse.ConsequenceLine(consequence); + } + if (string.IsNullOrEmpty(line)) continue; + if (!string.IsNullOrEmpty(consequence.ThreadId) && !seenThreads.Add(consequence.ThreadId)) + { + NarrativeChapter current = FindChapter(chapters, consequence.ThreadId); + string merged = NarrativeProse.TryMergeChapter(current?.Line, consequence); + if (current != null && !string.IsNullOrEmpty(merged)) + { + current.Line = merged; + current.Importance = Mathf.Max(current.Importance, consequence.Importance); + } + + continue; + } + + chapters.Add(new NarrativeChapter + { + Id = consequence.Id, + ThreadId = consequence.ThreadId, + Line = line, + DateLabel = consequence.DateLabel ?? "", + At = consequence.OccurredAt, + Importance = consequence.Importance + }); + } + + chapters.Sort((a, b) => b.Importance.CompareTo(a.Importance)); + return chapters; + } + + public static int CountConsequences(long characterId, CharacterConsequenceKind kind) + { + CharacterStoryState state = Character(characterId); + if (state == null) return 0; + int count = 0; + for (int i = 0; i < state.ConsequenceIds.Count; i++) + { + CharacterConsequence c = Consequence(state.ConsequenceIds[i]); + if (c != null && c.Kind == kind) count++; + } + + return count; + } + + private static CharacterStoryState GetOrCreateCharacter(long id, LifeSagaIdentity identity) + { + if (!Characters.TryGetValue(id, out CharacterStoryState state)) + { + state = new CharacterStoryState { CharacterId = id, Identity = identity }; + Characters[id] = state; + } + else if (identity.Id != 0) + { + state.Identity = identity; + } + + return state; + } + + private static void TouchDevelopment(CharacterStoryState state, NarrativeDevelopment development) + { + state.RecentDevelopmentIds.Insert(0, development.Id); + while (state.RecentDevelopmentIds.Count > RecentPerCharacter) + { + state.RecentDevelopmentIds.RemoveAt(state.RecentDevelopmentIds.Count - 1); + } + + state.LastMeaningfulChangeAt = development.OccurredAt; + state.StoryMomentum = Mathf.Min(12f, state.StoryMomentum + MomentumFor(development.Function)); + RecalculatePotential(state); + } + + private static void MoveThreadReference(CharacterStoryState state, NarrativeThread thread) + { + state.OpenThreadIds.Remove(thread.Id); + state.ResolvedThreadIds.Remove(thread.Id); + if (thread.IsOpen) state.OpenThreadIds.Insert(0, thread.Id); + else state.ResolvedThreadIds.Insert(0, thread.Id); + } + + private static void RecalculatePotential(CharacterStoryState state) + { + if (state == null) return; + float unresolved = state.OpenThreadIds.Count * 2.5f; + float consequences = Mathf.Min(6f, state.ConsequenceIds.Count * 0.8f); + state.StoryPotential = state.StoryMomentum + unresolved + consequences; + } + + private static float MomentumFor(NarrativeFunction function) + { + switch (function) + { + case NarrativeFunction.ThreadOpener: return 2f; + case NarrativeFunction.Development: return 1.5f; + case NarrativeFunction.Escalation: return 2.5f; + case NarrativeFunction.TurningPoint: return 4f; + case NarrativeFunction.Resolution: return 3.5f; + case NarrativeFunction.Consequence: return 3f; + default: return 0.25f; + } + } + + private static float UrgencyFor(NarrativeDevelopment development) + { + if (development == null) return 0f; + if (development.Function == NarrativeFunction.TurningPoint) return 4f; + if (development.Function == NarrativeFunction.Resolution) return 3f; + return Mathf.Clamp(development.Magnitude / 25f, 0f, 3f); + } + + private static string CastRole(NarrativeDevelopmentKind kind) + { + switch (kind) + { + case NarrativeDevelopmentKind.BondFormed: + case NarrativeDevelopmentKind.BondBroken: return "partner"; + case NarrativeDevelopmentKind.ChildBorn: return "child"; + case NarrativeDevelopmentKind.FriendFormed: return "friend"; + case NarrativeDevelopmentKind.Kill: + case NarrativeDevelopmentKind.RivalEncounter: return "opponent"; + default: return "involved"; + } + } + + private static NarrativeChapter FindChapter(List chapters, string threadId) + { + for (int i = 0; i < chapters.Count; i++) + { + if (chapters[i] != null && string.Equals(chapters[i].ThreadId, threadId, StringComparison.Ordinal)) + { + return chapters[i]; + } + } + + return null; + } +} diff --git a/IdleSpectator/Narrative/NarrativeTelemetry.cs b/IdleSpectator/Narrative/NarrativeTelemetry.cs new file mode 100644 index 0000000..681cdc7 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeTelemetry.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; + +namespace IdleSpectator; + +public sealed class NarrativeTelemetryEntry +{ + public float At; + public string ThreadId = ""; + public long ProtagonistId; + public string ProposedKey = ""; + public string ActualKey = ""; + public string Reason = ""; + public NarrativeInterruptClass InterruptClass; + public bool ActualRelated; +} + +/// Bounded in-memory trace for shadow-vs-actual story scheduling. +public static class NarrativeTelemetry +{ + private const int Cap = 256; + private static readonly List Entries = new List(Cap); + public static int Count => Entries.Count; + public static int RelatedActualCount { get; private set; } + public static int ComparedCount { get; private set; } + public static int RelatedProposalCount { get; private set; } + public static int CriticalProposalCount { get; private set; } + public static int AlignedSampleCount { get; private set; } + public static int PermittedInterruptSampleCount { get; private set; } + public static int PermittedContinuitySampleCount { get; private set; } + public static int UnalignedSampleCount { get; private set; } + public static int UnrelatedActualCount => ComparedCount - RelatedActualCount; + + public static void Clear() + { + Entries.Clear(); + RelatedActualCount = 0; + ComparedCount = 0; + RelatedProposalCount = 0; + CriticalProposalCount = 0; + AlignedSampleCount = 0; + PermittedInterruptSampleCount = 0; + PermittedContinuitySampleCount = 0; + UnalignedSampleCount = 0; + } + + public static void NoteProposal(EpisodePlan episode, EpisodeShotProposal proposal, float now) + { + if (episode == null || proposal?.Candidate == null) return; + if (proposal.InterruptClass == NarrativeInterruptClass.RelatedEscalation) RelatedProposalCount++; + if (proposal.InterruptClass == NarrativeInterruptClass.WorldCritical) CriticalProposalCount++; + Entries.Add(new NarrativeTelemetryEntry + { + At = now, + ThreadId = episode.ThreadId, + ProtagonistId = episode.ProtagonistId, + ProposedKey = proposal.Candidate.Key ?? "", + Reason = proposal.Reason ?? "", + InterruptClass = proposal.InterruptClass + }); + Trim(); + } + + public static void NoteActual(EpisodePlan episode, NarrativeThread thread, InterestCandidate actual) + { + if (episode == null || actual == null) return; + bool related = InterruptPolicy.IsRelated(actual, episode, thread); + ComparedCount++; + if (related) RelatedActualCount++; + NarrativeTelemetryEntry last = Entries.Count > 0 ? Entries[Entries.Count - 1] : null; + if (last != null && last.ThreadId == episode.ThreadId && string.IsNullOrEmpty(last.ActualKey)) + { + last.ActualKey = actual.Key ?? ""; + last.ActualRelated = related; + } + } + + public static void NoteCurrentAlignment( + EpisodePlan episode, + NarrativeThread thread, + InterestCandidate current) + { + if (episode == null || current == null) return; + if (InterruptPolicy.IsRelated(current, episode, thread)) + { + AlignedSampleCount++; + return; + } + + NarrativeInterruptClass kind = InterruptPolicy.Classify(current, episode, thread); + if (InterruptPolicy.MayInterrupt(kind)) + { + AlignedSampleCount++; + PermittedInterruptSampleCount++; + return; + } + + // Live sticky scenes are deliberately allowed to finish their immediate outcome + // before an episode reclaims the camera; report them separately from true drift. + if (current.Completion == InterestCompletionKind.CombatActive + || current.Completion == InterestCompletionKind.WarFront + || current.Completion == InterestCompletionKind.PlotActive + || current.Completion == InterestCompletionKind.StatusOutbreak) + { + AlignedSampleCount++; + PermittedContinuitySampleCount++; + return; + } + + UnalignedSampleCount++; + } + + public static string Summary => "compared=" + ComparedCount + + " actualRelated=" + RelatedActualCount + + " actualUnrelated=" + UnrelatedActualCount + + " proposals=" + Count + + " relatedProposals=" + RelatedProposalCount + + " criticalProposals=" + CriticalProposalCount + + " alignedSamples=" + AlignedSampleCount + + " permittedInterruptSamples=" + PermittedInterruptSampleCount + + " permittedContinuitySamples=" + PermittedContinuitySampleCount + + " unalignedSamples=" + UnalignedSampleCount; + + private static void Trim() + { + while (Entries.Count > Cap) Entries.RemoveAt(0); + } +} diff --git a/IdleSpectator/Narrative/NarrativeThreadRules.cs b/IdleSpectator/Narrative/NarrativeThreadRules.cs new file mode 100644 index 0000000..7068755 --- /dev/null +++ b/IdleSpectator/Narrative/NarrativeThreadRules.cs @@ -0,0 +1,265 @@ +using System; + +namespace IdleSpectator; + +/// Maps semantic developments onto durable character-centered threads. +public static class NarrativeThreadRules +{ + public static void Apply(NarrativeDevelopment d) + { + if (d == null || d.SubjectId == 0) return; + switch (d.Kind) + { + case NarrativeDevelopmentKind.BondFormed: + ApplyBondFormed(d); + break; + case NarrativeDevelopmentKind.BondBroken: + ApplyBondBroken(d); + break; + case NarrativeDevelopmentKind.ChildBorn: + ApplyChildBorn(d); + break; + case NarrativeDevelopmentKind.FriendFormed: + ApplyFriend(d); + break; + case NarrativeDevelopmentKind.Death: + ApplyDeath(d); + break; + case NarrativeDevelopmentKind.Kill: + case NarrativeDevelopmentKind.RivalEncounter: + ApplyConflict(d); + break; + case NarrativeDevelopmentKind.RoleGained: + case NarrativeDevelopmentKind.RoleLost: + ApplyPower(d); + break; + case NarrativeDevelopmentKind.HomeFounded: + case NarrativeDevelopmentKind.HomeGained: + case NarrativeDevelopmentKind.HomeLost: + ApplyHome(d); + break; + case NarrativeDevelopmentKind.WarJoined: + case NarrativeDevelopmentKind.WarEnded: + ApplyWar(d); + break; + case NarrativeDevelopmentKind.PlotJoined: + case NarrativeDevelopmentKind.PlotEnded: + ApplyPlot(d); + break; + case NarrativeDevelopmentKind.Transformation: + case NarrativeDevelopmentKind.Recovery: + ApplySurvival(d); + break; + } + } + + private static void ApplyBondFormed(NarrativeDevelopment d) + { + string anchor = PairAnchor("bond", d.SubjectId, d.OtherId); + string id = "thread:partnership:" + anchor + ":" + d.SubjectId; + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + id, NarrativeThreadKind.Partnership, d.SubjectId, anchor, d, + "How will this partnership shape their lives?", NarrativePhase.Outcome, + NarrativeThreadStatus.Active); + t?.AddCast(d.OtherId, "partner"); + AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.FoundLove, "partner", d.OtherId, 72f); + if (d.OtherId != 0) + { + string mirrorId = "thread:partnership:" + anchor + ":" + d.OtherId; + NarrativeThread mirror = NarrativeStoryStore.OpenOrUpdateThread( + mirrorId, NarrativeThreadKind.Partnership, d.OtherId, anchor, d, + "How will this partnership shape their lives?", NarrativePhase.Outcome, + NarrativeThreadStatus.Active); + mirror?.AddCast(d.SubjectId, "partner"); + AddConsequence(d, mirror, d.OtherId, CharacterConsequenceKind.FoundLove, "partner", d.SubjectId, 72f); + } + } + + private static void ApplyBondBroken(NarrativeDevelopment d) + { + string anchor = PairAnchor("bond", d.SubjectId, d.OtherId); + string id = "thread:partnership:" + anchor + ":" + d.SubjectId; + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + id, NarrativeThreadKind.SeparationGrief, d.SubjectId, anchor, d, + "What follows the loss of this partnership?", NarrativePhase.Consequence, + NarrativeThreadStatus.Resolved); + if (t != null) t.Resolution = "partnership ended"; + AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.LostPartner, "survivor", d.OtherId, 88f); + } + + private static void ApplyChildBorn(NarrativeDevelopment d) + { + string anchor = !string.IsNullOrEmpty(d.FamilyKey) ? d.FamilyKey : d.SubjectId.ToString(); + string id = "thread:lineage:" + anchor + ":" + d.SubjectId; + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + id, NarrativeThreadKind.Lineage, d.SubjectId, anchor, d, + "How will this growing lineage endure?", NarrativePhase.Outcome, + NarrativeThreadStatus.Active); + t?.AddCast(d.OtherId, "child"); + AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.BecameParent, "parent", d.OtherId, 76f); + + if (d.OtherId != 0) + { + string childId = "thread:lineage:" + anchor + ":" + d.OtherId; + NarrativeThread childThread = NarrativeStoryStore.OpenOrUpdateThread( + childId, NarrativeThreadKind.Lineage, d.OtherId, anchor, d, + "What life will begin from this lineage?", NarrativePhase.Setup, + NarrativeThreadStatus.Emerging); + childThread?.AddCast(d.SubjectId, "parent"); + AddConsequence(d, childThread, d.OtherId, CharacterConsequenceKind.WasBorn, "child", d.SubjectId, 65f); + } + } + + private static void ApplyFriend(NarrativeDevelopment d) + { + string anchor = PairAnchor("friend", d.SubjectId, d.OtherId); + NarrativeStoryStore.OpenOrUpdateThread( + "thread:partnership:" + anchor + ":" + d.SubjectId, + NarrativeThreadKind.Partnership, d.SubjectId, anchor, d, + "Where will this friendship lead?", NarrativePhase.Setup, NarrativeThreadStatus.Emerging); + } + + private static void ApplyDeath(NarrativeDevelopment d) + { + string id = "thread:life-end:" + d.SubjectId; + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + id, NarrativeThreadKind.SeparationGrief, d.SubjectId, id, d, + "Their life has ended", NarrativePhase.Outcome, NarrativeThreadStatus.Resolved); + if (t != null) t.Resolution = "died"; + AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.LostLife, "deceased", d.OtherId, 100f); + } + + private static void ApplyConflict(NarrativeDevelopment d) + { + string anchor = PairAnchor("conflict", d.SubjectId, d.OtherId); + NarrativeThreadKind kind = d.Kind == NarrativeDevelopmentKind.RivalEncounter + ? NarrativeThreadKind.Rivalry : NarrativeThreadKind.PersonalCombat; + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + "thread:conflict:" + anchor + ":" + d.SubjectId, kind, d.SubjectId, anchor, d, + "Will this conflict end their repeated struggle?", NarrativePhase.TurningPoint, + NarrativeThreadStatus.Active); + if (d.Kind == NarrativeDevelopmentKind.Kill) + { + AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.TookLife, "killer", d.OtherId, 82f); + } + } + + private static void ApplyPower(NarrativeDevelopment d) + { + string anchor = "role:" + d.SubjectId + ":" + d.NewState; + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + "thread:power:" + anchor, NarrativeThreadKind.RiseToRule, d.SubjectId, anchor, d, + "What will they do with their new standing?", NarrativePhase.Outcome, + d.Kind == NarrativeDevelopmentKind.RoleLost ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active); + AddConsequence(d, t, d.SubjectId, + d.Kind == NarrativeDevelopmentKind.RoleLost ? CharacterConsequenceKind.LostRole : CharacterConsequenceKind.RoseToRole, + "office holder", 0, 78f); + } + + private static void ApplyHome(NarrativeDevelopment d) + { + string anchor = FirstNonEmpty(d.CityKey, d.KingdomKey, d.CorrelationKey, d.SubjectId.ToString()); + bool lost = d.Kind == NarrativeDevelopmentKind.HomeLost; + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + "thread:home:" + anchor + ":" + d.SubjectId, + lost ? NarrativeThreadKind.HomeLoss : NarrativeThreadKind.Founding, + d.SubjectId, anchor, d, + lost ? "Where will they go after losing their home?" : "Will what they founded endure?", + lost ? NarrativePhase.TurningPoint : NarrativePhase.Outcome, + NarrativeThreadStatus.Active); + AddConsequence(d, t, d.SubjectId, + lost ? CharacterConsequenceKind.LostHome : CharacterConsequenceKind.FoundedHome, + lost ? "displaced" : "founder", 0, lost ? 90f : 82f); + } + + private static void ApplyWar(NarrativeDevelopment d) + { + string anchor = FirstNonEmpty(d.WarKey, d.CorrelationKey, "unknown"); + bool ended = d.Kind == NarrativeDevelopmentKind.WarEnded; + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + "thread:war:" + anchor + ":" + d.SubjectId, NarrativeThreadKind.WarInvolvement, + d.SubjectId, anchor, d, + ended ? "What did the war change for them?" : "Will they survive this war?", + ended ? NarrativePhase.Outcome : NarrativePhase.Pressure, + ended ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active); + AddConsequence(d, t, d.SubjectId, + ended ? CharacterConsequenceKind.SurvivedWar : CharacterConsequenceKind.EnteredWar, + "participant", d.OtherId, ended ? 86f : 62f); + } + + private static void ApplyPlot(NarrativeDevelopment d) + { + string anchor = FirstNonEmpty(d.PlotKey, d.CorrelationKey, "unknown"); + bool ended = d.Kind == NarrativeDevelopmentKind.PlotEnded; + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + "thread:plot:" + anchor + ":" + d.SubjectId, NarrativeThreadKind.PlotBetrayal, + d.SubjectId, anchor, d, + ended ? "What did the plot change?" : "Will their plot succeed?", + ended ? NarrativePhase.Outcome : NarrativePhase.Escalation, + ended ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active); + AddConsequence(d, t, d.SubjectId, + ended ? CharacterConsequenceKind.PlotResolved : CharacterConsequenceKind.JoinedPlot, + "plotter", d.OtherId, ended ? 78f : 58f); + } + + private static void ApplySurvival(NarrativeDevelopment d) + { + bool recovered = d.Kind == NarrativeDevelopmentKind.Recovery; + string anchor = FirstNonEmpty(d.CorrelationKey, "change:" + d.SubjectId + ":" + d.NewState); + NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( + "thread:survival:" + anchor, recovered ? NarrativeThreadKind.Recovery : NarrativeThreadKind.Transformation, + d.SubjectId, anchor, d, + recovered ? "How will recovery change their path?" : "Will they endure this transformation?", + recovered ? NarrativePhase.Outcome : NarrativePhase.TurningPoint, + recovered ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active); + AddConsequence(d, t, d.SubjectId, + recovered ? CharacterConsequenceKind.Recovered : CharacterConsequenceKind.Transformed, + "subject", d.OtherId, 72f); + } + + private static void AddConsequence( + NarrativeDevelopment d, + NarrativeThread thread, + long characterId, + CharacterConsequenceKind kind, + string role, + long otherId, + float importance) + { + LifeSagaIdentity other = otherId == d.OtherId ? d.Other : LifeSagaMemory.SnapshotId(otherId); + NarrativeStoryStore.AddConsequence(new CharacterConsequence + { + Id = "consequence:" + d.Id + ":" + characterId + ":" + kind, + CharacterId = characterId, + ThreadId = thread?.Id ?? "", + DevelopmentId = d.Id, + Kind = kind, + Role = role ?? "", + OtherId = otherId, + Other = other, + Context = FirstNonEmpty(d.WarKey, d.PlotKey, d.CityKey, d.KingdomKey), + Outcome = d.Outcome ?? "", + DateLabel = d.DateLabel ?? "", + OccurredAt = d.OccurredAt, + Importance = importance, + Confidence = d.Confidence + }); + } + + private static string PairAnchor(string prefix, long a, long b) + { + if (b == 0) return prefix + ":" + a; + return a <= b ? prefix + ":" + a + ":" + b : prefix + ":" + b + ":" + a; + } + + private static string FirstNonEmpty(params string[] values) + { + if (values == null) return ""; + for (int i = 0; i < values.Length; i++) + { + if (!string.IsNullOrEmpty(values[i])) return values[i]; + } + + return ""; + } +} diff --git a/IdleSpectator/Narrative/StoryScheduler.cs b/IdleSpectator/Narrative/StoryScheduler.cs new file mode 100644 index 0000000..4165fd0 --- /dev/null +++ b/IdleSpectator/Narrative/StoryScheduler.cs @@ -0,0 +1,357 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Story-first scheduler and sole camera-story owner. +/// +public static class StoryScheduler +{ + private const float EpisodeNoProgressSeconds = 14f; + private const float PayoffSettleSeconds = 8f; + private const float EpisodeMaxSeconds = 75f; + public const int EpisodeCombatShotBudget = 3; + private static readonly List OpenThreads = new List(32); + private static readonly List Pending = new List(96); + private static float _nextTickAt; + private static string _lastProposal = ""; + + public static EpisodePlan ActiveEpisode { get; private set; } + public static EpisodeShotProposal CurrentProposal { get; private set; } + public static string LastProposal => _lastProposal ?? ""; + + public static void Clear() + { + ActiveEpisode = null; + CurrentProposal = null; + OpenThreads.Clear(); + Pending.Clear(); + _nextTickAt = 0f; + _lastProposal = ""; + } + + public static void Tick(float now) + { + if (now < _nextTickAt) return; + _nextTickAt = now + 0.5f; + NarrativeStoryStore.CopyOpenThreads(OpenThreads); + NarrativeThread best = null; + float bestScore = float.MinValue; + for (int i = 0; i < OpenThreads.Count; i++) + { + NarrativeThread thread = OpenThreads[i]; + float score = Score(thread, now); + if (score > bestScore) + { + best = thread; + bestScore = score; + } + } + + NarrativeThread activeThread = ActiveEpisode != null + ? NarrativeStoryStore.Thread(ActiveEpisode.ThreadId) + : null; + if (ActiveEpisode != null && !EpisodeStillValid(ActiveEpisode, activeThread, now)) + { + ActiveEpisode = null; + CurrentProposal = null; + activeThread = null; + } + + if (best == null && activeThread == null) + { + ActiveEpisode = null; + CurrentProposal = null; + _lastProposal = "none"; + return; + } + + NarrativeThread selectedThread = RetainOrChoose(activeThread, best, bestScore, now); + if (selectedThread == null) + { + ActiveEpisode = null; + CurrentProposal = null; + _lastProposal = "none"; + return; + } + + if (ActiveEpisode == null || ActiveEpisode.ThreadId != selectedThread.Id) + { + ActiveEpisode = BuildEpisode(selectedThread, now); + } + + else if (selectedThread.LatestMeaningfulChangeId != ActiveEpisode.EntryDevelopmentId) + { + ActiveEpisode.EntryDevelopmentId = selectedThread.LatestMeaningfulChangeId; + ActiveEpisode.LastProgressAt = now; + ActiveEpisode.CombatShotCount = 0; + ActiveEpisode.GoalMet = false; + } + + InterestRegistry.CopyPending(Pending); + CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread); + if (CurrentProposal != null) + { + NarrativeTelemetry.NoteProposal(ActiveEpisode, CurrentProposal, now); + } + if (CurrentProposal?.Candidate != null) + { + NarrativeTelemetry.NoteCurrentAlignment( + ActiveEpisode, selectedThread, InterestDirector.CurrentCandidate); + } + + _lastProposal = selectedThread.Id + " protagonist=" + selectedThread.ProtagonistId + + " phase=" + selectedThread.Phase + " score=" + Score(selectedThread, now).ToString("0.0") + + " shot=" + (CurrentProposal?.Candidate?.Key ?? "none") + + " reason=" + (CurrentProposal?.Reason ?? "none"); + } + + public static void NoteActualSelection(InterestCandidate actual) + { + if (ActiveEpisode == null || actual == null) return; + NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId); + NarrativeTelemetry.NoteActual(ActiveEpisode, thread, actual); + NarrativeInterruptClass kind = InterruptPolicy.Classify(actual, ActiveEpisode, thread); + if (kind == NarrativeInterruptClass.RelatedEscalation) + { + if (IsCombatShot(actual)) + { + ActiveEpisode.CombatShotCount++; + } + ActiveEpisode.LastProgressAt = UnityEngine.Time.unscaledTime; + ActiveEpisode.LastShotKey = actual.Key ?? ""; + ActiveEpisode.InterruptedAt = -1f; + ActiveEpisode.GoalMet = thread != null + && (thread.Phase == NarrativePhase.Outcome + || thread.Phase == NarrativePhase.Consequence); + } + else if (kind == NarrativeInterruptClass.WorldCritical || kind == NarrativeInterruptClass.StoryPayoff) + { + ActiveEpisode.InterruptedAt = UnityEngine.Time.unscaledTime; + } + } + + /// + /// A narrative episode may establish and develop a fight without following every new + /// opponent. A decisive world-critical combat signal still bypasses the budget. + /// + public static bool MayUseCombatShot(EpisodePlan episode, InterestCandidate candidate) + { + if (episode == null || candidate == null || !IsCombatShot(candidate)) + { + return true; + } + + if (candidate.EventStrength >= 95f) + { + return true; + } + + return episode.CombatShotCount < EpisodeCombatShotBudget; + } + + public static bool MayUseCombatShot(InterestCandidate candidate) => + MayUseCombatShot(ActiveEpisode, candidate); + + /// Camera-owner selection from candidates already admitted by director safety gates. + public static InterestCandidate SelectForCamera( + IList eligible, + InterestCandidate current, + float now) + { + if (ActiveEpisode == null) return null; + NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId); + if (!EpisodeStillValid(ActiveEpisode, thread, now)) + { + ActiveEpisode = null; + CurrentProposal = null; + return null; + } + + EpisodeShotProposal proposal = EpisodeShotSelector.Pick(eligible, ActiveEpisode, thread); + CurrentProposal = proposal; + if (proposal?.Candidate == null || proposal.Candidate == current) return null; + return proposal.Candidate; + } + + public static bool ShouldHoldForEpisode(float now) + { + if (ActiveEpisode == null + || CurrentProposal?.Candidate == null) return false; + NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId); + return EpisodeStillValid(ActiveEpisode, thread, now) + && now - ActiveEpisode.LastProgressAt < EpisodeNoProgressSeconds; + } + + public static bool HarnessProbePolicy(out string detail) + { + var episode = new EpisodePlan { ThreadId = "probe:a", ProtagonistId = 101 }; + episode.EligibleCastIds.Add(102); + var thread = new NarrativeThread + { + Id = "probe:a", + ProtagonistId = 101, + AnchorKey = "family-a", + Status = NarrativeThreadStatus.Active + }; + var candidates = new List + { + new InterestCandidate { Key = "unrelated", Label = "Unrelated event", SubjectId = 201, EventStrength = 90f }, + new InterestCandidate + { + Key = "related", Label = "Family develops", AssetId = "add_child", + SubjectId = 102, EventStrength = 60f + } + }; + EpisodeShotProposal relatedPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false); + var episodeB = new EpisodePlan { ThreadId = "probe:b", ProtagonistId = 201 }; + var threadB = new NarrativeThread + { + Id = "probe:b", + ProtagonistId = 201, + AnchorKey = "family-b", + Status = NarrativeThreadStatus.Active + }; + EpisodeShotProposal concurrentPick = EpisodeShotSelector.Pick( + candidates, episodeB, threadB, requirePresentable: false); + candidates.Add(new InterestCandidate + { + Key = "critical", Label = "Kingdom shattered", SubjectId = 301, + Category = "Disaster", EventStrength = 100f + }); + EpisodeShotProposal criticalPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false); + NarrativeInterruptClass ordinary = InterruptPolicy.Classify(candidates[0], episode, thread); + var saturated = new EpisodePlan + { + ThreadId = "probe:combat", + ProtagonistId = 101, + CombatShotCount = EpisodeCombatShotBudget + }; + var routineCombat = new InterestCandidate + { + SubjectId = 101, + Completion = InterestCompletionKind.CombatActive, + EventStrength = 80f + }; + var criticalCombat = new InterestCandidate + { + SubjectId = 101, + Completion = InterestCompletionKind.CombatActive, + EventStrength = 100f + }; + bool routineBlocked = !MayUseCombatShot(saturated, routineCombat); + bool criticalAllowed = MayUseCombatShot(saturated, criticalCombat); + bool nonCombatAllowed = MayUseCombatShot(saturated, candidates[1]); + bool pass = relatedPick?.Candidate?.Key == "related" + && concurrentPick?.Candidate?.Key == "unrelated" + && criticalPick?.Candidate?.Key == "critical" + && ordinary == NarrativeInterruptClass.OrdinaryInteresting + && !InterruptPolicy.MayInterrupt(ordinary) + && routineBlocked && criticalAllowed && nonCombatAllowed; + detail = "related=" + (relatedPick?.Candidate?.Key ?? "none") + + " concurrent=" + (concurrentPick?.Candidate?.Key ?? "none") + + " critical=" + (criticalPick?.Candidate?.Key ?? "none") + + " ordinary=" + ordinary + + " combatBudget=" + routineBlocked + "/" + criticalAllowed + "/" + nonCombatAllowed + + " pass=" + pass; + return pass; + } + + public static float StoryPotential(long characterId) + { + CharacterStoryState state = NarrativeStoryStore.Character(characterId); + return NarrativeStoryStore.EffectiveStoryPotential(state); + } + + private static float Score(NarrativeThread thread, float now) + { + if (thread == null + || (!thread.IsOpen + && !(thread.Status == NarrativeThreadStatus.Resolved && now - thread.UpdatedAt < 20f))) + { + return float.MinValue; + } + CharacterStoryState state = NarrativeStoryStore.Character(thread.ProtagonistId); + float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now); + float freshness = Mathf.Max(0f, 8f - (now - thread.UpdatedAt) / 15f); + float payoff = thread.Phase == NarrativePhase.TurningPoint ? 8f + : thread.Phase == NarrativePhase.Outcome ? 7f + : thread.Phase == NarrativePhase.Consequence ? 6f : 0f; + float prefer = LifeSagaRoster.IsPrefer(thread.ProtagonistId) ? 8f : 0f; + 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 + + freshness + payoff + prefer + mc + repeat; + } + + private static NarrativeThread RetainOrChoose( + NarrativeThread active, + NarrativeThread best, + float bestScore, + float now) + { + if (active == null) return best; + if (best == null || best.Id == active.Id) return active; + float activeScore = Score(active, now); + bool settled = ActiveEpisode != null && ActiveEpisode.GoalMet + && now - ActiveEpisode.LastProgressAt >= PayoffSettleSeconds; + bool stale = ActiveEpisode != null + && now - ActiveEpisode.LastProgressAt >= EpisodeNoProgressSeconds; + return settled || stale || bestScore >= activeScore + 20f ? best : active; + } + + private static bool EpisodeStillValid(EpisodePlan episode, NarrativeThread thread, float now) + { + bool recentResolution = thread != null + && thread.Status == NarrativeThreadStatus.Resolved + && now - thread.UpdatedAt < 20f; + if (episode == null || thread == null || (!thread.IsOpen && !recentResolution)) return false; + if (now - episode.StartedAt >= EpisodeMaxSeconds) return false; + if (episode.GoalMet && now - episode.LastProgressAt >= PayoffSettleSeconds) return false; + return now - episode.LastProgressAt < EpisodeNoProgressSeconds + || thread.LatestMeaningfulChangeId != episode.EntryDevelopmentId; + } + + private static EpisodePlan BuildEpisode(NarrativeThread thread, float now) + { + var episode = new EpisodePlan + { + ThreadId = thread.Id, + ProtagonistId = thread.ProtagonistId, + EntryDevelopmentId = thread.LatestMeaningfulChangeId, + Purpose = PurposeFor(thread), + InformationGoal = thread.CentralQuestion ?? "", + StartedAt = now, + LastProgressAt = now + }; + for (int i = 0; i < thread.Cast.Count; i++) + { + NarrativeCastRole cast = thread.Cast[i]; + if (cast != null && cast.CharacterId != 0) episode.EligibleCastIds.Add(cast.CharacterId); + } + + return episode; + } + + private static bool IsCombatShot(InterestCandidate candidate) + { + return candidate != null + && (candidate.Completion == InterestCompletionKind.CombatActive + || string.Equals(candidate.AssetId, "live_combat", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(candidate.AssetId, "live_battle", System.StringComparison.OrdinalIgnoreCase)); + } + + private static EpisodePurpose PurposeFor(NarrativeThread thread) + { + if (thread == null) return EpisodePurpose.Develop; + switch (thread.Phase) + { + case NarrativePhase.Setup: return EpisodePurpose.Establish; + case NarrativePhase.TurningPoint: + case NarrativePhase.Outcome: return EpisodePurpose.Payoff; + case NarrativePhase.Consequence: return EpisodePurpose.Consequence; + default: return EpisodePurpose.Develop; + } + } +} diff --git a/IdleSpectator/Story/CaptionComposer.cs b/IdleSpectator/Story/CaptionComposer.cs index be62657..3f41524 100644 --- a/IdleSpectator/Story/CaptionComposer.cs +++ b/IdleSpectator/Story/CaptionComposer.cs @@ -80,6 +80,24 @@ public static class CaptionComposer model.ReasonRelatedId = reasonRelatedId; string beat = presentableBeat ?? ""; + string narrativeContext = ""; + bool structuredNarrativeBeat = false; + if (!statusBannerOwnsBeat + && NarrativeProse.TryBuildBeat( + focus, + ownedTip, + out NarrativeBeatModel narrativeModel, + out string narrativeBeat, + out narrativeContext)) + { + beat = narrativeBeat; + structuredNarrativeBeat = true; + if (narrativeModel.OtherId != 0) + { + model.ReasonRelatedId = narrativeModel.OtherId; + } + } + if (statusBannerOwnsBeat) { // Banner owns orange row; composer still fills Identity + Context. @@ -87,7 +105,9 @@ public static class CaptionComposer } else { - model.BeatLine = EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry); + model.BeatLine = structuredNarrativeBeat + ? beat + : EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry); if (!isReentry) { model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine); @@ -101,6 +121,10 @@ public static class CaptionComposer model.IdentityLine, spineLabel, isReentry && !statusBannerOwnsBeat); + if (!string.IsNullOrEmpty(narrativeContext)) + { + model.ContextLine = narrativeContext; + } model.Fingerprint = focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine diff --git a/IdleSpectator/Story/CausalHeat.cs b/IdleSpectator/Story/CausalHeat.cs deleted file mode 100644 index 62faee7..0000000 --- a/IdleSpectator/Story/CausalHeat.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; - -namespace IdleSpectator; - -/// -/// Decaying per-unit heat from featured cast. Soft-spreads to live relations and Chronicle links. -/// -public static class CausalHeat -{ - private struct HeatEntry - { - public float Value; - public float TouchedAt; - } - - private static readonly Dictionary HeatById = new Dictionary(64); - private static readonly List ScratchIds = new List(16); - private const float FeaturedAmount = 1f; - private const float RelatedSpread = 0.45f; - - public static void Clear() - { - HeatById.Clear(); - } - - public static void NoteFeatured(long unitId, float amount = FeaturedAmount) - { - if (unitId == 0 || amount <= 0f) - { - return; - } - - float now = Time.unscaledTime; - AddHeat(unitId, amount, now); - SpreadRelated(unitId, amount * RelatedSpread, now); - } - - public static void NoteCast(IList castIds, float amount = FeaturedAmount) - { - if (castIds == null || castIds.Count == 0) - { - return; - } - - for (int i = 0; i < castIds.Count; i++) - { - NoteFeatured(castIds[i], amount); - } - } - - public static float Get(long unitId) - { - if (unitId == 0 || !HeatById.TryGetValue(unitId, out HeatEntry entry)) - { - return 0f; - } - - return Decayed(entry, Time.unscaledTime); - } - - public static float ScoreBonus(InterestCandidate c) - { - if (c == null) - { - return 0f; - } - - StoryWeights s = InterestScoringConfig.Story; - float subject = Get(c.SubjectId); - float related = c.RelatedId != 0 && c.RelatedId != c.SubjectId ? Get(c.RelatedId) : 0f; - return subject * s.causalHeatWeight + related * s.causalRelatedWeight; - } - - private static float Decayed(HeatEntry entry, float now) - { - StoryWeights s = InterestScoringConfig.Story; - float halfLife = s.causalHeatHalfLifeSeconds > 1f ? s.causalHeatHalfLifeSeconds : 180f; - float age = Mathf.Max(0f, now - entry.TouchedAt); - return entry.Value * Mathf.Pow(0.5f, age / halfLife); - } - - private static void AddHeat(long unitId, float amount, float now) - { - float cur = 0f; - if (HeatById.TryGetValue(unitId, out HeatEntry entry)) - { - cur = Decayed(entry, now); - } - - HeatById[unitId] = new HeatEntry - { - Value = Mathf.Min(4f, cur + amount), - TouchedAt = now - }; - } - - private static void SpreadRelated(long unitId, float amount, float now) - { - if (amount < 0.05f) - { - return; - } - - Actor actor = EventFeedUtil.FindAliveById(unitId); - if (actor != null) - { - Actor lover = ActorRelation.GetLover(actor); - if (lover != null) - { - AddHeat(EventFeedUtil.SafeId(lover), amount, now); - } - - Actor friend = ActorRelation.GetBestFriend(actor); - if (friend != null) - { - AddHeat(EventFeedUtil.SafeId(friend), amount * 0.75f, now); - } - } - - ScratchIds.Clear(); - Chronicle.EnumerateRelatedIds(unitId, ScratchIds, max: 6); - for (int i = 0; i < ScratchIds.Count; i++) - { - long other = ScratchIds[i]; - if (other == 0 || other == unitId) - { - continue; - } - - AddHeat(other, amount * 0.5f, now); - } - } -} diff --git a/IdleSpectator/Story/LifeSagaMemory.cs b/IdleSpectator/Story/LifeSagaMemory.cs index 8e2d174..f29c637 100644 --- a/IdleSpectator/Story/LifeSagaMemory.cs +++ b/IdleSpectator/Story/LifeSagaMemory.cs @@ -297,6 +297,7 @@ 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") @@ -342,6 +343,7 @@ 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") @@ -360,6 +362,7 @@ public static class LifeSagaMemory 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") @@ -376,6 +379,7 @@ public static class LifeSagaMemory 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") @@ -395,6 +399,7 @@ 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); @@ -422,6 +427,7 @@ public static class LifeSagaMemory provenance: provenance, resolved: true, note: attackType ?? ""); + NarrativeDevelopmentRouter.Death(victim, killer, attackType, provenance); if (killer != null) { @@ -644,6 +650,7 @@ public static class LifeSagaMemory warKey: warKey, resolved: ended, note: note ?? ""); + NarrativeDevelopmentRouter.War(subject, other, warKey, ended, provenance); } /// @@ -738,6 +745,7 @@ public static class LifeSagaMemory plotKey: plotKey, note: display, resolved: finished); + NarrativeDevelopmentRouter.Plot(subject, other, plotKey, finished, provenance); } /// @@ -977,6 +985,7 @@ 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") @@ -994,6 +1003,7 @@ public static class LifeSagaMemory strength: 68f, provenance: provenance, note: roleId); + NarrativeDevelopmentRouter.Role(subject, roleId, true, provenance); } public static void RecordHome(Actor subject, bool gained, string provenance = "happiness") @@ -1013,33 +1023,6 @@ public static class LifeSagaMemory provenance: provenance); } - public static void RecordHardArc( - long unitId, - StoryArcKind arcKind, - string note, - long relatedId = 0, - string provenance = "story_planner") - { - if (unitId == 0) - { - return; - } - - LifeSagaIdentity subject = SnapshotId(unitId); - LifeSagaIdentity other = relatedId != 0 ? SnapshotId(relatedId) : default; - LifeSagaFactKind kind = HardArcKind(arcKind); - RecordIds( - kind, - unitId, - subject, - relatedId, - other, - correlationKey: "arc:" + unitId + ":" + arcKind + ":" + (note ?? "").GetHashCode(), - strength: 60f, - provenance: provenance, - note: note ?? ""); - } - public static LifeSagaIdentity Snapshot(Actor actor) { if (actor == null) @@ -1786,13 +1769,11 @@ public static class LifeSagaMemory { if (a != 0) { - CausalHeat.NoteFeatured(a, 1.15f); LifeSagaRoster.NoteFeaturedUnit(a, 1.2f); } if (b != 0 && b != a) { - CausalHeat.NoteFeatured(b, 1.15f); LifeSagaRoster.NoteFeaturedUnit(b, 1.2f); } } @@ -1859,26 +1840,6 @@ public static class LifeSagaMemory return false; } - private static LifeSagaFactKind HardArcKind(StoryArcKind arcKind) - { - switch (arcKind) - { - case StoryArcKind.Love: - return LifeSagaFactKind.HardArcLove; - case StoryArcKind.Grief: - return LifeSagaFactKind.HardArcGrief; - case StoryArcKind.CombatDuel: - case StoryArcKind.CombatMass: - return LifeSagaFactKind.HardArcCombat; - case StoryArcKind.WarFront: - return LifeSagaFactKind.HardArcWar; - case StoryArcKind.Plot: - return LifeSagaFactKind.HardArcPlot; - default: - return LifeSagaFactKind.Unknown; - } - } - private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind) { switch (kind) diff --git a/IdleSpectator/Story/LifeSagaPresentation.cs b/IdleSpectator/Story/LifeSagaPresentation.cs index 6e531f6..29c79fd 100644 --- a/IdleSpectator/Story/LifeSagaPresentation.cs +++ b/IdleSpectator/Story/LifeSagaPresentation.cs @@ -650,14 +650,103 @@ public static class LifeSagaPresentation 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) @@ -725,6 +814,7 @@ public static class LifeSagaPresentation } if (!wroteParentChild + && !narrativeOwnsFamily && parentedCount >= 2 && model.Legacy.Count < 4) { @@ -745,6 +835,81 @@ public static class LifeSagaPresentation } } + 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. diff --git a/IdleSpectator/Story/LifeSagaRoster.cs b/IdleSpectator/Story/LifeSagaRoster.cs index 08fb893..3210ad5 100644 --- a/IdleSpectator/Story/LifeSagaRoster.cs +++ b/IdleSpectator/Story/LifeSagaRoster.cs @@ -26,7 +26,10 @@ public static class LifeSagaRoster private static readonly Dictionary SpeciesCountCache = new Dictionary(); private static readonly HashSet McOrbitCache = new HashSet(); private static readonly List TouchScratch = new List(8); + private static readonly List NarrativeChallengerScratch = + new List(8); private static float _nextScanAt; + private static float _nextNarrativeAdmissionAt; private static float _roleCacheAt = -999f; private static bool _dirty = true; private static WorldScanPhase _scanPhase = WorldScanPhase.None; @@ -77,7 +80,9 @@ public static class LifeSagaRoster PlotAuthorCache.Clear(); SpeciesCountCache.Clear(); McOrbitCache.Clear(); + NarrativeChallengerScratch.Clear(); _nextScanAt = 0f; + _nextNarrativeAdmissionAt = 0f; _roleCacheAt = -999f; _scanPhase = WorldScanPhase.None; _scanIndex = 0; @@ -699,156 +704,16 @@ public static class LifeSagaRoster s.TouchedAt = now; s.Heat = Mathf.Min(HeatCap, s.Heat + 1.5f); _dirty = true; - - LifeSagaMemory.RecordHardArc( - unitId, - kind == LifeSagaChapterKind.Love ? StoryArcKind.Love - : kind == LifeSagaChapterKind.Grief ? StoryArcKind.Grief - : kind == LifeSagaChapterKind.Combat ? StoryArcKind.CombatDuel - : kind == LifeSagaChapterKind.War ? StoryArcKind.WarFront - : kind == LifeSagaChapterKind.Plot ? StoryArcKind.Plot - : StoryArcKind.None, - trimmed); - } - - /// - /// Hard-arc admit heat grows with climax age so prolonged theater can challenge a full Cap. - /// One-shot heat 1.5 loses to a roster of notables; ~2 minutes reaches . - /// - public static float HardArcAdmitHeat(StoryArc arc, float now = -1f) - { - if (now < 0f) - { - now = Time.unscaledTime; - } - - if (arc == null || arc.StartedAt <= 0f) - { - return 1.5f; - } - - float age = Mathf.Max(0f, now - arc.StartedAt); - return Mathf.Min(HeatCap, 1.5f + age / 12f); - } - - public static void StampChapterFromArc(StoryArc arc, InterestCandidate tip, float now) - { - if (arc == null || !arc.IsActive) - { - return; - } - - string line = FormatChapterLine(arc, tip); - if (string.IsNullOrEmpty(line)) - { - return; - } - - bool hard = IsHardStoryKind(arc.Kind); - float subjectHeat = hard ? HardArcAdmitHeat(arc, now) : 1.5f; - float relatedHeat = hard ? Mathf.Max(1.2f, subjectHeat * 0.85f) : 1.2f; - if (arc.CastIds != null) - { - for (int i = 0; i < arc.CastIds.Count; i++) - { - long id = arc.CastIds[i]; - if (hard) - { - ConsiderAdmit(id, subjectHeat, hardArcAdmit: true, now); - MarkHardArcContext(id, arc.Kind, line); - } - - if (IsMc(id)) - { - LifeSagaMemory.RecordHardArc(id, arc.Kind, line, tip?.RelatedId ?? 0); - StampChapter(id, ToChapterKind(arc.Kind), line, now); - } - } - } - - if (tip != null) - { - if (hard) - { - ConsiderAdmit(tip.SubjectId, subjectHeat, hardArcAdmit: true, now); - MarkHardArcContext(tip.SubjectId, arc.Kind, line); - if (tip.RelatedId != 0) - { - ConsiderAdmit(tip.RelatedId, relatedHeat, hardArcAdmit: true, now); - MarkHardArcContext(tip.RelatedId, arc.Kind, line); - } - - long follow = EventFeedUtil.SafeId(tip.FollowUnit); - if (follow != 0 && follow != tip.SubjectId && follow != tip.RelatedId) - { - ConsiderAdmit(follow, subjectHeat, hardArcAdmit: true, now); - MarkHardArcContext(follow, arc.Kind, line); - } - } - - if (IsMc(tip.SubjectId)) - { - StampChapter(tip.SubjectId, ToChapterKind(arc.Kind), line, now); - } - - if (tip.RelatedId != 0 && IsMc(tip.RelatedId)) - { - StampChapter(tip.RelatedId, ToChapterKind(arc.Kind), line, now); - } - - long followStamp = EventFeedUtil.SafeId(tip.FollowUnit); - if (followStamp != 0 - && followStamp != tip.SubjectId - && followStamp != tip.RelatedId - && IsMc(followStamp)) - { - StampChapter(followStamp, ToChapterKind(arc.Kind), line, now); - } - } - } - - public static string FormatChapterLine(StoryArc arc, InterestCandidate tip) - { - if (arc == null) - { - return ""; - } - - if (!string.IsNullOrEmpty(arc.Headline)) - { - return CleanChapterLine(arc.Headline); - } - - if (tip != null && !string.IsNullOrEmpty(tip.Label)) - { - string cleaned = CleanChapterLine(tip.Label); - if (!string.IsNullOrEmpty(cleaned)) - { - return cleaned; - } - } - - switch (arc.Kind) - { - case StoryArcKind.WarFront: - return "War front"; - case StoryArcKind.CombatDuel: - return "Duel"; - case StoryArcKind.CombatMass: - return "Battle"; - case StoryArcKind.Love: - return "Love"; - case StoryArcKind.Grief: - return "Grief"; - case StoryArcKind.Plot: - return "Plot"; - default: - return ""; - } } public static void Tick(float now) { + if (now >= _nextNarrativeAdmissionAt) + { + _nextNarrativeAdmissionAt = now + 1f; + ConsiderEmergingStories(now); + } + if (_scanPhase != WorldScanPhase.None) { ContinueAmortizedWorldScan(now); @@ -1086,9 +951,13 @@ public static class LifeSagaRoster /// /// Challenge Cap with a living unit. Admission roles always may compete; - /// nobodies only when (hard-arc chapter path). + /// ordinary characters only when coherent story momentum qualifies them. /// - public static bool ConsiderAdmit(long unitId, float heat, bool hardArcAdmit, float now = -1f) + public static bool ConsiderAdmit( + long unitId, + float heat, + float now = -1f, + bool emergingStoryAdmit = false) { if (unitId == 0) { @@ -1117,7 +986,7 @@ public static class LifeSagaRoster EnsureRoleCache(now, force: false); bool admission = IsAdmissionCandidate(actor); - if (!admission && !hardArcAdmit) + if (!admission && !emergingStoryAdmit) { return false; } @@ -1125,7 +994,8 @@ public static class LifeSagaRoster LifeSagaSlot neu = BuildSlot( actor, now, - hardArcAdmit && !admission ? LifeSagaAdmissionReason.HardArc : LifeSagaAdmissionReason.None); + emergingStoryAdmit && !admission ? LifeSagaAdmissionReason.EmergingStory + : LifeSagaAdmissionReason.None); neu.Heat = Mathf.Min(HeatCap, Mathf.Max(0f, heat)); Scratch.Clear(); PreviousIds.Clear(); @@ -1150,6 +1020,22 @@ public static class LifeSagaRoster return IsMc(unitId); } + private static void ConsiderEmergingStories(float now) + { + NarrativeStoryStore.CopyEmergingCharacters(NarrativeChallengerScratch, 6f, 4); + for (int i = 0; i < NarrativeChallengerScratch.Count; i++) + { + CharacterStoryState state = NarrativeChallengerScratch[i]; + if (Get(state.CharacterId) != null) continue; + float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now); + ConsiderAdmit( + state.CharacterId, + heat: Mathf.Clamp(potential * 0.5f, 1f, HeatCap), + now: now, + emergingStoryAdmit: true); + } + } + private static void NoteUnit(long unitId, float heat, float now) { LifeSagaSlot s = Get(unitId); @@ -1527,7 +1413,8 @@ public static class LifeSagaRoster } } - float score = standing + heat; + float storyPotential = Mathf.Min(12f, StoryScheduler.StoryPotential(s.UnitId) * 0.8f); + float score = standing + heat + storyPotential; if (s.GameFavorite) { score += 50f; @@ -2400,19 +2287,6 @@ public static class LifeSagaRoster return LifeSagaAdmissionReason.NotableLife; } - private static void MarkHardArcContext(long unitId, StoryArcKind kind, string line) - { - LifeSagaSlot slot = Get(unitId); - if (slot == null || slot.AdmissionReason != LifeSagaAdmissionReason.HardArc) - { - return; - } - - slot.AdmissionArcKind = kind; - slot.AdmissionContext = CleanChapterLine(line); - _dirty = true; - } - private static float BaseInterest(Actor actor) { float score = 1f; @@ -2482,34 +2356,6 @@ public static class LifeSagaRoster return score; } - private static bool IsHardStoryKind(StoryArcKind kind) => - kind == StoryArcKind.CombatDuel - || kind == StoryArcKind.CombatMass - || kind == StoryArcKind.WarFront - || kind == StoryArcKind.Plot - || kind == StoryArcKind.Love - || kind == StoryArcKind.Grief; - - private static LifeSagaChapterKind ToChapterKind(StoryArcKind kind) - { - switch (kind) - { - case StoryArcKind.CombatDuel: - case StoryArcKind.CombatMass: - return LifeSagaChapterKind.Combat; - case StoryArcKind.WarFront: - return LifeSagaChapterKind.War; - case StoryArcKind.Plot: - return LifeSagaChapterKind.Plot; - case StoryArcKind.Love: - return LifeSagaChapterKind.Love; - case StoryArcKind.Grief: - return LifeSagaChapterKind.Grief; - default: - return LifeSagaChapterKind.Unknown; - } - } - private static string CleanChapterLine(string line) { if (string.IsNullOrEmpty(line)) @@ -2620,8 +2466,6 @@ public sealed class LifeSagaSlot public float TouchedAt; public float LastChapterAt; public LifeSagaAdmissionReason AdmissionReason; - public StoryArcKind AdmissionArcKind; - public string AdmissionContext = ""; public readonly List Chapters = new List(LifeSagaRoster.MaxChapters); @@ -2650,7 +2494,7 @@ public enum LifeSagaAdmissionReason NotableKills, NotableRenown, NotableLife, - HardArc + EmergingStory } public enum LifeSagaChapterKind diff --git a/IdleSpectator/Story/LifeSagaSession.cs b/IdleSpectator/Story/LifeSagaSession.cs index dfcb557..8e7f4bb 100644 --- a/IdleSpectator/Story/LifeSagaSession.cs +++ b/IdleSpectator/Story/LifeSagaSession.cs @@ -33,6 +33,7 @@ public static class LifeSagaSession LifeSagaViewController.Clear(); LifeSagaRail.Clear(); LifeSagaPanel.Clear(); + NarrativeStoryStore.Clear(); _epoch++; object world = SafeWorld(); diff --git a/IdleSpectator/Story/SagaProse.cs b/IdleSpectator/Story/SagaProse.cs index 6f1efcb..a1132c6 100644 --- a/IdleSpectator/Story/SagaProse.cs +++ b/IdleSpectator/Story/SagaProse.cs @@ -712,6 +712,8 @@ public static class SagaProse string c = FirstNonEmpty(slot?.CityKey, CityName(actor)); return string.IsNullOrEmpty(c) ? Sentence("Leads a city") : Sentence("Leads " + c); } + case LifeSagaAdmissionReason.EmergingStory: + return Sentence("A life in motion"); default: return ""; } diff --git a/IdleSpectator/Story/StoryPlanner.cs b/IdleSpectator/Story/StoryPlanner.cs index afbb577..3519b41 100644 --- a/IdleSpectator/Story/StoryPlanner.cs +++ b/IdleSpectator/Story/StoryPlanner.cs @@ -211,12 +211,6 @@ public static class StoryPlanner _active.HardHold = hardHold || _active.HardHold; NoteCastFrom(current, _active); StampHeadline(_active, current); - // Re-challenge Cap while climax holds: admit heat escalates with arc age so a - // prolonged duel/war/plot on a stranger can still join the rail (Active chrome). - if (IsHardStoryKind(_active.Kind)) - { - LifeSagaRoster.StampChapterFromArc(_active, current, Time.unscaledTime); - } } private static bool IsCombatKind(StoryArcKind kind) => @@ -355,7 +349,6 @@ public static class StoryPlanner _coldHookFired = false; _coldHookClimaxKey = ""; _coldHookAnchor = ""; - CausalHeat.Clear(); // LifeSagaRoster survives short-arc Clear (browse / brief idle-off). } @@ -752,12 +745,6 @@ public static class StoryPlanner return; } - CausalHeat.NoteFeatured(next.SubjectId); - if (next.RelatedId != 0) - { - CausalHeat.NoteFeatured(next.RelatedId, 0.6f); - } - LifeSagaRoster.NoteFeatured(next); if (IsStoryAssetCandidate(next)) @@ -797,7 +784,6 @@ public static class StoryPlanner } NoteCastFrom(next, _active); - LifeSagaRoster.StampChapterFromArc(_active, next, now); } return; @@ -808,11 +794,6 @@ public static class StoryPlanner if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold)) { BeginOrRefreshArc(next, kind, hardHold, now); - if (_active != null && _active.IsActive) - { - LifeSagaRoster.StampChapterFromArc(_active, next, now); - } - return; } @@ -1420,7 +1401,6 @@ public static class StoryPlanner _coldHookFired = false; _coldHookClimaxKey = ""; _coldHookAnchor = ""; - CausalHeat.NoteCast(_active.CastIds); TrimParkedBoard(); } @@ -2183,7 +2163,6 @@ public static class StoryPlanner MaxWatch = registered.MaxWatch, SynthesizeIfMissing = false }); - CausalHeat.NoteFeatured(followId); return true; } @@ -2194,139 +2173,9 @@ public static class StoryPlanner return; } - // Crisis chapter owns the closer - do not also fire a related soft epilogue. - if (_crisis != null && _crisis.IsLive) - { - if (!_crisis.EpilogueInjected) - { - BeginCrisisEpilogue(now); - } - - if (_crisis.EpilogueInjected) - { - arc.Advance(StoryPhase.Done, now); - return; - } - } - - Actor follow = null; - Actor related = null; - ActorScratch.Clear(); - for (int i = 0; i < arc.CastIds.Count; i++) - { - Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]); - if (a == null) - { - continue; - } - - Actor lover = ActorRelation.GetLover(a); - Actor friend = ActorRelation.GetBestFriend(a); - bool hasBond = (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a)) - || (friend != null && friend.isAlive()); - if (hasBond) - { - ActorScratch.Add(a); - } - } - - Actor preferred = LifeSagaRoster.PreferRosterUnit(ActorScratch); - if (preferred != null) - { - follow = preferred; - related = ActorRelation.GetLover(preferred); - if (related == null || !related.isAlive() || EventFeedUtil.SafeId(related) == EventFeedUtil.SafeId(preferred)) - { - related = ActorRelation.GetBestFriend(preferred); - } - } - else - { - for (int i = 0; i < arc.CastIds.Count; i++) - { - Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]); - if (a == null) - { - continue; - } - - Actor lover = ActorRelation.GetLover(a); - if (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a)) - { - follow = a; - related = lover; - break; - } - - Actor friend = ActorRelation.GetBestFriend(a); - if (friend != null && friend.isAlive()) - { - follow = a; - related = friend; - break; - } - - if (follow == null) - { - follow = a; - } - } - } - - if (follow == null) - { - arc.Advance(StoryPhase.Done, now); - return; - } - - // Need a distinct related for a meaningful epilogue. - if (related == null) - { - arc.Advance(StoryPhase.Done, now); - return; - } - - StoryWeights s = InterestScoringConfig.Story; - string assetId = StoryReason.EpilogueRelated; - string label = StoryReason.AftermathLabel(assetId, follow, related); - string key = "epilogue:" + arc.Kind + ":" + arc.AnchorKey; - long followId = EventFeedUtil.SafeId(follow); - long relatedId = EventFeedUtil.SafeId(related); - - var candidate = new InterestCandidate - { - Key = key, - LeadKind = InterestLeadKind.EventLed, - Category = "Story", - Source = "story_planner", - AssetId = assetId, - Label = label, - FollowUnit = follow, - SubjectId = followId, - RelatedUnit = related, - RelatedId = relatedId, - Position = follow.current_position, - EventStrength = s.epilogueStrength, - VisualConfidence = 0.7f, - Novelty = 1f, - MinWatch = 8f, - MaxWatch = s.epilogueMaxWatch > 0f ? s.epilogueMaxWatch : 14f, - Completion = InterestCompletionKind.FixedDwell, - Resumable = false, - CorrelationKey = "story:" + arc.AnchorKey, - CreatedAt = now, - LastSeenAt = now, - ExpiresAt = now + 40f - }; - - if (EventFeedUtil.RegisterCandidate(candidate) == null) - { - arc.Advance(StoryPhase.Done, now); - return; - } - - arc.Advance(StoryPhase.Epilogue, now); - CausalHeat.NoteFeatured(followId); + // Narrative threads carry consequences forward. The old synthetic related-character + // closer duplicated that responsibility and generated generic unsupported epilogues. + arc.Advance(StoryPhase.Done, now); } private static Actor ResolveAftermathFollow(InterestCandidate climax) @@ -3134,10 +2983,6 @@ public static class StoryPlanner EpilogueInjected = false }; StampCrisisTheater(_crisis, InterestDirector.CurrentCandidate); - if (followId != 0) - { - CausalHeat.NoteFeatured(followId, 1.1f); - } } private static void StampCrisisTheater(CrisisChapter crisis, InterestCandidate tip) @@ -3474,7 +3319,6 @@ public static class StoryPlanner } crisis.FollowId = followId; - CausalHeat.NoteFeatured(followId, 1.2f); return true; } } diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index b59692f..f01d5ad 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -1122,12 +1122,6 @@ public static class WorldActivityScanner score += Mathf.Max(5f, InterestScoringConfig.Story.sagaMcWeight * 0.7f); } - float causal = CausalHeat.Get(id); - if (causal > 0f) - { - score += causal * 2f; - } - return score; } diff --git a/README.md b/README.md index d7eb3a5..8e935cc 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director. ## What it follows -The camera follows **ranked events** from live feeds (wars, fights, relationships, buildings, statuses, plots, …). -`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending. -Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job). +The camera follows the developing lives and relationships of a small emerging cast. A story +scheduler chooses developments that advance an active character thread, yields to critical world +events, and returns only when the story has new evidence. `EventStrength` still models urgency and +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. @@ -24,7 +26,7 @@ Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps - `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML - `scripts/verify-nml.sh` - load verification helper -- `docs/event-reason.md` - events-own-camera + EventReason contract +- `docs/event-reason.md` - evidence, orange-reason, and camera-alignment contract ## Deploy diff --git a/docs/event-reason.md b/docs/event-reason.md index b749fd2..e60286d 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -11,13 +11,15 @@ Story commitment (climax → aftermath → epilogue) is owned by [`StoryPlanner` Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only. Discrete / status / WorldLog: `CreatesInterest` = A gate. -`InterestDirector` only ranks A candidates. It does not author inventory. +`InterestDirector` applies liveness and presentability gates to A candidates; the story scheduler +chooses episode developments before bounded ambient/world fallback. Neither authors inventory. 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`). -Identity titles and stake slogans never enter `InterestCandidate.Label` - `identity_filter` / `IsIdentityWhy` stay event-first. +Identity titles and stake slogans never enter `InterestCandidate.Label`; `identity_filter` / +`IsIdentityWhy` remain selection safety gates. The focused unit must be a tip principal: - Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff). - Pair-locked duels: PairOwner/PairPartner only. diff --git a/docs/life-saga.md b/docs/life-saga.md index bc8709c..543ee4d 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -43,15 +43,18 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th - Cap stays 10; order follows Effective = Standing + Heat + pin bonuses. - Prefer and WorldBox favorites are hard pins and fully count toward Cap. - Admission-role units always compete on the periodic scan. -- Non-admission "nobodies" challenge Cap only via hard-arc chapter stamps (Combat / War / Plot / Love / Grief). -- Hard-arc admit heat escalates with climax age (re-stamped while the arc holds) so a prolonged stranger duel can displace dull Cap slots and light Active chrome. +- Ordinary non-notables challenge the Cap through decaying story potential earned from confirmed + semantic developments. +- Prolonged spectacle alone does not admit a character; coherent relationship, lineage, power, + home, conflict, war, plot, or survival momentum can. - Soft `NoteFeatured` heats existing MCs only. - Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash. ## Camera (soft) - No rail camera lock. Prefer is a toggle soft-favorite for idle scoring. -- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > MC cast > CausalHeat. +- Score ladder: critical interrupt > narrative episode > Prefer soft > MC soft > MC cast > bounded + ambient/world context. - `nearTieEpsilon` (8) picks #1 for ordinary gaps; Prefer/MC/cast may still win within `sagaNearTieEpsilon` (28). - Soft ranks: Prefer > CrossMC (>1 roster MC) > MC > MC cast (lover/friend/kin/earned rival of a roster MC). - Tips that touch two or more roster MCs get `sagaCrossMcBonus` (~11); scoring touch inventory matches rail Involved ids. @@ -72,7 +75,8 @@ Always-on AFK chrome (no Dossier/Saga tabs): | 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`. -Selection/`identity_filter` stay event-first. +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). Status banners still own/suppress Beat; Identity + Context keep updating. Outside hover, caption Identity/Beat/Context track camera focus. During Saga hover, the diff --git a/docs/mc-story-director-rewrite-plan.md b/docs/mc-story-director-rewrite-plan.md new file mode 100644 index 0000000..a952528 --- /dev/null +++ b/docs/mc-story-director-rewrite-plan.md @@ -0,0 +1,1078 @@ +# MC story director rewrite plan + +## Status + +Implementation complete. Story-first is the sole production camera path. + +Checkpoint 1 (2026-07-22) is complete: + +- Added semantic developments, character story state, durable threads, consequences, chapters, + structured narrative beats, and episode models under `IdleSpectator/Narrative/`. +- Dual-write is live for bonds, children, friendship, death, kills, roles, founding, wars, and plots. +- Family/lineage, power, home, conflict, war, plot, and survival thread rule foundations exist. +- Structured relationship/child Beats can replace concatenated event prose. +- Narrative chapters take precedence over copied hard-arc prose in Legacy. +- The story scheduler runs in shadow mode and does not yet own the camera. +- World-session reset clears narrative state. +- Live NML compilation passes; `saga_legacy_quality`, `caption_character_beat`, and `settings_full` + pass after the checkpoint. + +Checkpoint 2 (2026-07-22) is complete: + +- Episode shot proposals now select protagonist/cast/theater-related pending candidates. +- Explicit interrupt classes distinguish related escalation, story payoff, world-critical events, + ordinary interesting events, and texture/noise. +- Ordinary unrelated events cannot interrupt a productive episode; critical events can. +- Bounded shadow telemetry compares proposed story shots with actual event-first selections. +- Actual camera switches feed the comparison trace without changing camera ownership. +- `narrative_scheduler_policy` covers related selection, concurrent-thread scoping, ordinary-event + rejection, and world-critical interruption. + +Checkpoint 3 (2026-07-22) is complete: + +- `story_first_director` now grants the scheduler guarded camera-selection ownership. +- Scheduler choices are made only from candidates already admitted by the director's liveness, + ownership, presentability, completion, cooldown, and switch-safety filters. +- Episodes retain their thread across ordinary events and critical interruptions. +- Payoff episodes settle, stale episodes expire, and normal world coverage resumes instead of + starving the camera when no related development appears. +- A new development on the same thread reopens its information goal. +- Recently resolved threads remain schedulable briefly so outcomes and consequences can be shown. +- `narrative_camera_cutover` proves a lower-strength related development wins over an unrelated + event, then yields to a world-critical interrupt through the real director selection path. +- The feature remains disabled by default pending longer enabled-mode soak coverage. + +Checkpoint 4 (2026-07-22) is complete: + +- Story potential now decays over time and supplies a bounded, scan-free challenger lane into the + MC roster. An ordinary living character can enter as `EmergingStory` once coherent developments + produce enough momentum. +- Effective roster interest includes a capped story-potential component, so current narrative + movement competes with office, fame, favorites, and manual preference without replacing them. +- `NarrativeFunctionPolicy` classifies candidates as texture, world context, opener, development, + escalation, turning point, resolution, or consequence independently of spectacle strength. +- Episode shot scoring and interrupt policy consume that classification. Noise cannot become an + episode shot merely by touching the cast; character texture does not interrupt unrelated stories. +- Telemetry distinguishes related proposals, critical proposals, and related versus unrelated actual + cuts. `narrative_story_inputs` proves emerging-MC admission and semantic classification, while + `narrative_story_first_soak` exercises real enabled camera ownership and continuity assertions. + +Checkpoint 5 (2026-07-22) is complete: + +- Every candidate is stamped at the single registry intake boundary with a durable narrative + function and its provenance: exact catalog mapping, typed structural rule, or compatibility + fallback. Downstream selection no longer has to reinterpret labels repeatedly. +- The initial explicit catalog covers relationship, lineage, war, plot, combat, disaster, political, + loss, aftermath, milestone, and everyday texture events. Dynamic theaters retain typed structural + mappings. +- Coverage telemetry records unique candidates and their function distribution. A 20-second natural + enabled-mode soak typed 74 of 79 observed candidates through catalog or structural rules (94%). +- Natural telemetry exposed cast-related routine behavior being mislabeled as escalation. The policy + now treats noise and character texture as non-progress even when they touch the active cast. +- Scheduler telemetry also samples whether the camera is already aligned with the active episode, + covering the common case where a development opens a thread after its camera cut. The tuned natural + soak recorded 38 aligned samples and zero unaligned samples without manufacturing extra cuts. +- `narrative_catalog_policy` and `narrative_natural_soak` are part of the regression scenario list. + The catalog policy, natural soak, scheduler policy, camera cutover, synthetic enabled soak, Legacy + quality, and settings compatibility scenarios pass after the tuning. + +Checkpoint 6 (2026-07-22) is complete: + +- A harness-only force flag now runs every compatibility scenario with story-first camera ownership, + while the shipped default remains guarded. Narrative state is cleared between scenario batches. +- The deterministic multi-thread stress covers initial selection, retention against a competitor, + critical interruption, return to the interrupted episode, abandonment, and handoff to the next + thread. +- Episode-related developments can reclaim the camera from ordinary unrelated material after a + short settling beat. Live fights, crises, payoffs, and other explicitly protected continuity still + finish their immediate outcome. +- Episodes without presentable footage no longer reserve an empty camera slot; normal ambient + selection remains available until a related shot exists. +- Orange-reason telemetry is cleared when scene ownership ends, preventing a stale duel/event label + from surviving an ambient focus handoff. +- WorldLog feeds retain an explicitly supplied event participant even when the vanilla asset hides + its follow-location UI, fixing the missing `disaster_alien_invasion` candidate. +- The full forced story-first regression passes 19/19 scenarios. The live-event pipeline reports no + failed authored cases, and the presentability scenario passes all 65 assertions. +- Three final 20-second natural soaks observed 265, 173, and 119 unique candidates. Catalog or + structural typing covered 98%, 96%, and 100%; compatibility fallback accounted for 6, 7, and 0. + Camera health passed in all runs. True unaligned samples were 1, 0, and 1, with critical/payoff + interruptions reported separately instead of being mislabeled as drift. + +Checkpoint 7 (2026-07-22) is complete: + +- A five-minute story-first soak and five-minute event-first baseline ran against the same developed + world. Both modes remained presentable: neither produced bad camera state, placeholder prose, + subject/reason mismatch, sleep lag, or object-focus failures. +- Story-first produced 33 authored camera reasons versus 14 for event-first and accumulated 48 + narrative facts for the recurring lead versus 33 in the baseline. The story-first run revisited + relationships and a family ensemble, and three repeated encounters earned a rivalry; the + event-first baseline did not earn one. +- The comparison exposed a remaining biography defect rather than a camera defect: repeated kills + could still appear as several independent `Slew ...` Legacy lines. Legacy now consolidates two or + more confirmed kills into one dated repeated-clashes chapter while preserving a separately earned + rivalry chapter. +- `saga_legacy_combat_summary` proves that three kill facts render as one combat summary with no raw + kill lines. Existing `saga_legacy_quality` coverage still passes all 43 checks. +- Story-first camera ownership is now the shipped default. `story_first_director` remains available + as an explicit event-first opt-out during the rollback window. +- The complete default-on release regression passes 19/19 scenarios, including all 65 + presentability assertions and the live-event pipeline. + +Checkpoint 8 (2026-07-23) is complete: + +- The first extended organic default-on session produced 333 authored cuts and 593 healthy focus + samples with no bad focus state, placeholder, object-focus failure, fight/focus mismatch, or + sleeping/fighting caption lag. One unpresentable candidate was rejected before selection. +- The session demonstrated genuine recurring leads: Zagatze and Xe Ze appeared in 51 and 54 + authored reasons, with repeated encounters, aftermath, relationship, family, and leadership + material. Story-first continuity is therefore surviving beyond synthetic and five-minute tests. +- The same evidence exposed an editorial pacing defect. Combat occupied 157 of 333 cuts (47%), + including 92 duel cards, and unchanged combat phases, family-pack cards, and generic + `seeks ... after the story` epilogues repeated too freely. +- Identical sticky combat, war, plot, outbreak, family-pack, and synthetic-story cards now receive + a short editorial replay cooldown after selection. A distinct opponent or changed card remains + eligible, and ordinary moment beats retain their existing cooldown policy. +- The old generic related-character epilogue is no longer synthesized while story-first owns the + camera; the structured narrative thread already carries that consequence. Event-first retains + the closer only as rollback compatibility. +- A two-minute post-tuning natural sample passed with 11 cuts, no camera/prose failures, no generic + related epilogue, two duel cuts, and four multi-unit combat cuts. One battlefield card returned + after intervening developments rather than as an immediate replay. +- `narrative_editorial_replay`, scheduler policy, combat-story, and multi-thread stress scenarios + pass. The complete forced story-first regression still passes 19/19 scenarios, including all 65 + presentability assertions. + +Rejected comparison window (2026-07-23): + +- The attempted second extended soak was not a directed spectator session. Idle Spectator first + became active at log line 360 of 397, after roughly 91% of the captured window had already + elapsed, and produced only five authored cuts before shutdown. +- Those active cuts were healthy, but the sample is too small to support a pacing or rollback + decision. The fallback path remains in place. +- The soak-audit tool now reports idle-mode True/False samples and the location of the first active + state, and explicitly flags windows where Idle Spectator activated late. + +Checkpoint 9 (2026-07-23) is complete: + +- A valid five-minute default-on sample produced 36 authored cuts and 54 healthy active/focus + samples. It had no bad camera state, unpresentable selection, placeholder prose, + subject/reason mismatch, or sleeping/fighting caption lag. +- Relationship material rose to 9 of 36 cuts and repeatedly returned to parenthood, children, + partners, grief, pregnancy, and gifts. Norron and Tampoo received developments across multiple + life stages rather than isolated notifications. +- The replay guard worked: the generic after-story closer did not return, and exact sticky-card + repetition was limited to an ongoing combat phase after intervening cuts. +- Combat nevertheless occupied 17 of 36 cuts (47%). The closing sequence followed Iraum through + six consecutive combat cards against distinct opponents, revealing episode saturation that an + exact-card replay guard cannot address. +- Each narrative episode now has a budget of three routine combat shots. A confirmed meaningful + development resets the budget, non-combat developments remain eligible, and decisive combat + with event strength 95 or higher may exceed the budget so the director does not miss a climax. +- The scheduler probe proves saturated routine combat is rejected while critical combat and + non-combat developments remain eligible. Narrative scheduler, multi-thread stress, editorial + replay, and combat-story scenarios pass. +- The complete forced story-first regression passes 19/19 scenarios. Normal default-on + story-first configuration was restored after the run. + +Checkpoint 10 (2026-07-23) completes the cutover: + +- The ten-minute post-budget organic release soak produced 53 authored cuts and 107 healthy + active/focus samples. It had zero bad camera states, placeholder tips, object-focus failures, + fight/focus mismatches, or sleeping/fighting caption lag. +- Combat fell from 47% in both earlier extended samples to 11 of 53 cuts (20.8%), comfortably under + the 35% release target. The longest uninterrupted combat run was two cuts. +- The same sample retained world coverage and recurring life stories. Waf, Waaf, and Woof returned + through love, companionship, reproduction, mating, pregnancy, and children alongside adulthood, + founding, leadership, plague, discovery, and decisive combat outcomes. +- The runtime `story_first_director` and `narrative_shadow` flags and their harness override were + removed. The scheduler now has first ownership on every selection pass; bounded ambient/world + selection remains only when no episode owns a usable development. +- The synthetic related-character epilogue implementation was deleted. Narrative threads are the + only continuation owner. +- Hard-arc headline-to-memory writes and combat-spectacle MC admission were removed. Ordinary + characters now enter the ensemble through confirmed narrative momentum rather than prolonged + theater exposure. +- Obsolete `CausalHeat` scoring, scanner bias, and lifecycle writes were removed. Narrative episode + relation, story potential, and the MC roster provide the remaining intentional continuity bias. +- The soak audit now reports combat share, maximum combat run, relationship count, and unique-tip + coverage; a sample with at least 20 cuts fails the editorial gate above 35% combat. +- The final cleaned build compiles in NML. Camera cutover, scheduler, multi-thread, replay, natural + story, roster replacement, and role-stake targets pass, followed by a complete 19/19 regression. +- A final five-minute soak after deleting causal heat and hard-arc admission produced 35 cuts and + 59 healthy active/focus samples with no camera or prose failures. Active combat was 4 of 35 + (11.4%) with a maximum run of one, while Theangaldar progressed from reproduction intent through + pregnancy, child, parenthood, and death and Aethamred progressed from courtship through love and + pregnancy. + +The rollback window is closed. Story-first is the sole production path and all eight implementation +phases have passed their exit gates. + +This plan supersedes the existing event-first story direction as the product target. Existing +story documents remain useful implementation history, but they are not constraints on this +rewrite. + +## Product vision + +IdleSpectator follows the developing lives of a small cast of main characters. The camera may +leave them for world-changing spectacle, but it should return because their circumstances have +changed, not merely because they remain important or recently appeared. + +At a glance, the player should understand: + +1. **Whose life are we following?** +2. **What changed for them just now?** +3. **What unresolved question or pressure does that change belong to?** +4. **What lasting consequence did earlier events have on their life?** + +The intended experience is closer to an automatically edited ensemble chronicle than a ranked +event feed. + +## Product contract + +### Story-first selection + +The primary director decision is: + +> Which new development best advances a life or relationship the player is following? + +It is not: + +> Which isolated event currently has the highest spectacle score? + +Event strength remains valuable for urgency and interrupts. It is no longer the primary model of +narrative value. + +### Evidence-first narration + +The system may infer narrative structure from confirmed observations, but it must not invent facts. +It may say that a war threatens a character when that character, their family, city, or kingdom is +observably involved. It may not claim fear, intent, causation, victory, succession, or grief without +supporting evidence. + +### Consequences over notifications + +Prefer state transitions and outcomes over repeated intent or activity: + +- `Found love with Omyale` over `is seeking a lover` once love is confirmed. +- `Welcomed a first child with Omyale` over `gains a child · their child`. +- `Survived the fall of Super Pumpkin` over `Super Pumpkin (84) vs The Godo (46)`. +- `Lost Ylhofne during the war` over two unrelated Legacy fragments. + +### Coherent episodes, not camera lock + +The director commits to a developing thread long enough to establish context and show a meaningful +change. Related cast and theater shots are valid parts of that episode. Urgent world events can cut +in, but ordinary unrelated events should not continually reset the story. + +### Biography, not archived UI copy + +Legacy is synthesized from structured, character-perspective consequences. Orange reasons, +scoreboards, camera labels, and internal story headlines must never be copied directly into Legacy. + +## Rewrite boundary + +### Preserve + +- Game observation patches and feeds that detect real events. +- `EventOutcome` / observation confirmation rules. +- Live actor, relationship, war, plot, status, and ensemble inspection. +- Camera movement, focus repair, hold mechanics, and presentability checks. +- The current Identity, Beat, Context, Cast, and Legacy UI surfaces. +- Chronicle as the complete historical journal. +- Existing event catalog as an observation/prose source while migration is underway. +- Useful deterministic harness infrastructure. + +### Replace or substantially reshape + +- Global event-candidate ranking as the main narrative decision. +- `StoryArc` as the primary story representation. +- Climax-first `Climax -> Aftermath -> Epilogue` planning. +- Heat as a substitute for story continuity. +- Hard-arc camera headlines stored as life facts. +- Legacy selection as recent facts from diverse enum buckets. +- Caption enrichment based on concatenating independently authored strings. +- MC admission based mainly on role, spectacle, and recent feature heat. + +### Compatibility during migration + +The old director remains available behind a configuration flag until the new path passes the story +quality gates. Both paths may consume the same observations, but only one path owns camera selection +at a time. + +## Target architecture + +```text +Game observations + | + v +Confirmed semantic developments + | \ + v -> Chronicle journal +Character state + relationships + | + v +Durable narrative threads + | + v +Story scheduler -> episode plan -> shot selector -> camera + | | + v v +Beat + Context live focus + | + v +Consequences -> chapters -> Legacy +``` + +The critical inversion is that observations update story state before the director chooses what to +show. Camera selection consumes that story state instead of attempting to manufacture continuity +after a global event candidate wins. + +## Core domain model + +### NarrativeDevelopment + +A typed, evidence-backed state change emitted by observation adapters. + +Suggested fields: + +```text +Id +Kind +OccurredAt / WorldDate +SubjectId +OtherIds +Place / City / Kingdom / Family / War / Plot ids +PreviousState +NewState +Outcome +Magnitude +Confidence +EvidenceSource +CorrelationKeys +Presentability +``` + +Examples: + +- Bond formed or broken +- Child born, including parent perspectives +- Role gained or lost +- Home founded, gained, lost, or destroyed +- War joined, left, won, lost, or survived +- Plot joined, exposed, completed, or failed +- Rival encounter, escalation, defeat, or death +- Transformation, infection, cure, mutation, or species extinction pressure +- Meaningful combat outcome +- Death and its observed effects on connected lives + +This model records meaning, not final English. + +### CharacterStoryState + +Durable state for every character with story evidence, whether or not they currently occupy the +visible roster. + +Suggested fields: + +```text +CharacterId +Identity snapshot +Current roles and affiliations +Living relationships +OpenThreadIds +ResolvedThreadIds +RecentDevelopmentIds +ConsequenceIds +StoryMomentum +StoryPotential +LastMeaningfulChangeAt +``` + +Memory must survive roster churn. Joining the roster affects presentation and scheduling priority, +not whether a life can accumulate a coherent history. + +### NarrativeThread + +A durable unresolved question involving a protagonist and supporting cast. + +Suggested fields: + +```text +ThreadId +ThreadKind +ProtagonistId +CastIds with roles +Anchor ids +OpenedByDevelopmentId +CurrentPhase +CentralQuestion +Pressure / stakes evidence +DevelopmentIds +LatestMeaningfulChangeId +Momentum +Urgency +Coherence +Novelty +CoverageDebt +Status: Emerging | Active | Dormant | Resolved | Abandoned +Resolution +``` + +Initial thread kinds: + +| Family | Power | Conflict | Survival | Journey | +|---|---|---|---|---| +| Courtship | Rise to rule | Rivalry | Transformation | Founding | +| Partnership | Reign | Revenge | Outbreak | Migration | +| Lineage | Succession | War involvement | Last of kind | Exile/home loss | +| Separation/grief | Plot/betrayal | Personal combat | Recovery | Return/homecoming | + +Thread kinds should be extended from observed play, not from a goal of catalog completeness. + +### Narrative phase + +Use phases that describe development rather than camera mechanics: + +```text +Setup -> Pressure -> Escalation -> TurningPoint -> Outcome -> Consequence +``` + +Phases may be skipped. A thread can become dormant in any unresolved phase. A resolution requires +an observed outcome; timeout alone makes a thread dormant or abandoned, not resolved. + +### CharacterConsequence + +A durable character-perspective result suitable for later chapter synthesis. + +Suggested fields: + +```text +CharacterId +ThreadId +DevelopmentId +ConsequenceKind +Role in event +OtherIds +Before / after state +Outcome confidence +Importance +WorldDate +``` + +Examples from the same battle may differ per character: + +- Defender: `Defended Super Pumpkin against The Godo`. +- Survivor: `Survived the fall of Super Pumpkin`. +- Bereaved partner: `Lost Ylhofne during the war`. +- Attacker: `Helped The Godo defeat Super Pumpkin`. + +Only emit the most specific statement supported by evidence. + +### EpisodePlan + +A temporary editorial commitment to one thread. + +Suggested fields: + +```text +ThreadId +ProtagonistId +EligibleCastIds +EligibleTheater +EntryDevelopmentId +Purpose: Establish | Develop | Payoff | Consequence | Reintroduce +MinimumInformationGoal +StartedAt +CoverageState +InterruptState +``` + +An episode ends because its information goal was met, its subject became unavailable, the thread +went dormant, or a stronger story requires the camera. It should not end solely because a fixed +dwell timer expired. + +### NarrativeBeatModel + +Structured presentation model shared by orange Beat and muted Context. + +Suggested fields: + +```text +PerspectiveCharacterId +DevelopmentId +ThreadId +ActionKind +OtherIds and relationship roles +Change +Outcome +ThreadPhase +ContextEvidence +Confidence +``` + +The prose renderer receives this model and decides what information belongs in Identity, Beat, and +Context. It must perform semantic deduplication before producing strings. + +## Story scheduler + +### Thread scoring + +Score stories, not raw events. Initial factors: + +- New meaningful change +- Approaching or confirmed payoff +- Existing player preference +- MC or emerging-MC involvement +- Unresolved pressure +- Repeated cast and relationship continuity +- Cross-MC interaction +- Coverage debt +- Visual presentability +- Urgency +- Recent overexposure penalty +- Dormancy and stale-evidence penalty + +Raw spectacle affects urgency and interrupt eligibility. It should not automatically make a weakly +connected event a better story than a meaningful development in an established life. + +### Episode entry + +An episode may begin when: + +- A new thread opens with sufficient story potential. +- An active thread receives a meaningful development. +- A thread reaches a turning point, outcome, or consequence. +- A preferred character returns with genuinely new information. +- A dormant thread becomes active again. + +Do not begin an episode for repeated attempts, ordinary maintenance activity, or an unchanged live +state unless it is an establishing shot needed for an imminent payoff. + +### Shot selection inside an episode + +Eligible shots include: + +- The protagonist performing or receiving the development. +- A directly involved supporting character. +- The relevant family, city, kingdom, war front, plot cell, or disaster theater. +- A short grounding shot that establishes the protagonist before a payoff. +- A consequence shot after a confirmed outcome. + +Every shot must be able to answer why it belongs to the active episode. + +### Interrupts + +Define explicit interrupt classes: + +- **World critical:** major disaster, kingdom destruction, huge war turning point. May interrupt. +- **Story payoff:** another high-priority thread resolves. Usually may interrupt. +- **Related escalation:** belongs to active protagonist/cast. Fold into the episode. +- **Ordinary interesting event:** queue or ignore while the episode remains productive. +- **Texture/noise:** never interrupts. + +After an interrupt, resume only if the previous thread still has an unmet information goal or a new +development. Do not resume because an old score or heat value remains high. + +### World coverage + +The system must still make the world feel alive. Maintain a coverage budget rather than globally +ranking every event against every character beat: + +- Most time follows active MC stories. +- Some time introduces emerging lives. +- A bounded share shows world context and critical spectacle. +- Quiet time grounds characters or advances low-intensity threads. + +Exact shares and episode durations are tuning outcomes, not hard-coded product requirements. + +## MC roster redesign + +Keep the visible roster at ten unless UI testing suggests otherwise, but separate roster membership +from active narrative attention. + +### Cohorts + +- **Active ensemble:** a small set of lives with strong current story momentum. +- **Roster:** visible noteworthy lives with durable importance or story history. +- **Emerging:** off-roster lives accumulating a coherent thread. +- **Preferred:** explicit player preference; strong scheduling prior, not an absolute camera lock. + +### Story potential + +Calculate story potential from: + +- Strength and number of unresolved threads +- Recent meaningful state changes +- Recurring cast and relationships +- Proximity to a turning point or outcome +- Cross-MC connections +- Reversal or contrast in life trajectory +- Distinctiveness from the current ensemble +- Existing coherent history + +Role, renown, kills, rarity, and favorite status remain useful standing signals. They should not be +the only reliable route into the cast. + +### Admission and retirement + +- Admit emerging characters because a thread is developing, not because a spectacle lasted long + enough. +- Retain a life while it has an active thread, strong unresolved consequence, player preference, or + durable historical importance. +- Allow dormant characters to rotate out without deleting story memory. +- Reintroduce them with concise context when a thread reactivates. + +## Orange Beat and Context + +### Ownership + +- **Identity:** who this is now. +- **Beat:** the confirmed development occurring now. +- **Context:** the unresolved question, pressure, or consequence that makes the Beat matter. + +### Prose rules + +1. Render from `NarrativeBeatModel`, not directly from `InterestCandidate.Label`. +2. Choose one perspective character before writing. +3. Prefer outcomes and state changes over intent. +4. Do not repeat a relationship already expressed by the Beat. +5. Do not repeat a title or role already expressed by Identity. +6. Do not repeat the same fact in Context. +7. Use names when they distinguish the relationship or stakes. +8. Omit Context when no supported, useful context exists. +9. Never expose raw asset ids, enum values, correlation keys, or internal phase names. +10. Never upgrade uncertainty into fact. + +### Example transformations + +| Observation | Weak presentation | Target presentation | +|---|---|---| +| Child added | `Gains a child, Omyale · their child` | `Welcomes a child, Omyale` | +| First child confirmed | same | `Welcomes a first child, Omyale` | +| Child continues threatened lineage | same | Beat: `Welcomes Omyale`; Context: `Their lineage continues` | +| Battle begins | `A (84) vs B (46)` | Beat may retain theater framing while live | +| Settlement later falls | same line in Legacy | `Survived the fall of A` when supported | +| Lover dies during war | `Lost Ylhofne` | `Lost Ylhofne during the war` when correlated | + +The renderer should choose natural grammar for species and reproduction mode where known. The table +describes information structure, not a single universal English template. + +## Legacy redesign + +### Chapter synthesis + +Legacy is a short biography assembled from consequences, not a list of event records. Related +developments may collapse into one chapter. + +Example: + +```text +Joon 145 · Fell in love with Ylhofne +Joon 152 · Lost Ylhofne during the war with The Godo +Meow 145 · Survived the fall of Super Pumpkin +Meow 161 · Founded a new home in Northreach +``` + +### Selection priorities + +Rank candidate chapters by: + +1. Confirmed lasting consequence +2. Identity or affiliation change +3. Relationship change +4. Personal role and specificity +5. Causal importance to later chapters +6. Resolution +7. Narrative diversity +8. Recency as a tie-breaker, not the primary rule + +### Consolidation + +Consolidate when developments share a thread and the later event supplies a clearer consequence: + +- Courtship + bond formed -> one relationship beginning. +- Repeated fights + credible rivalry -> one rivalry chapter. +- War joined + settlement destroyed + survival -> one war consequence when space is limited. +- Multiple children -> first notable child plus a lineage summary where useful. + +### Exclusions + +Never place these directly in Legacy: + +- Live scoreboards or side counts +- `A vs B` theater headlines without a personal outcome +- Internal story phase names +- Repeated intents +- Routine activity +- Current state already fully expressed by Identity or Cast +- An unresolved claim worded as a completed achievement + +## Event catalog reclassification + +Catalog completeness remains useful for observation coverage. Add narrative function independently +from camera strength: + +```text +ThreadOpener +Development +Escalation +TurningPoint +Resolution +Consequence +CharacterTexture +WorldContext +Noise +``` + +Each emitted development also declares whether it changes state. Repeated observations that do not +change state may refresh liveness but must not create new chapters or story momentum. + +Initial policy pass should focus on the event classes that produce most visible narrative value: + +1. Relationships, children, family, and death +2. Leadership, founding, succession, and home loss +3. War, plots, rivalry, and personal combat outcomes +4. Transformation, disease, mutation, and recovery +5. Books, traits, items, and other identity texture +6. World spectacle and ambient events + +Do not rewrite every catalog string before the structured pipeline proves useful. + +## Implementation phases + +### Phase 0 - Baseline and story-quality instrumentation + +Build measurements before changing selection. + +Deliverables: + +- A soak trace that records camera shots, selected subject, active thread, episode purpose, + development id, interrupt reason, Beat, Context, and resulting chapter. +- Baseline metrics from the current director. +- Deterministic multi-event life scenarios. +- A feature flag for `event_first` versus `story_first` selection. + +Required scenarios: + +- Courtship -> bond -> child -> separation/death +- Nobody -> founder -> leader -> home loss +- Repeated opponents -> credible rivalry -> decisive outcome +- War join -> settlement destruction -> survivor consequence +- Transformation/infection -> crisis -> cure or death +- Two simultaneous MC threads plus a world-critical interrupt + +Exit gate: the current system has a reproducible narrative-quality baseline. + +### Phase 1 - Semantic development layer + +Introduce `NarrativeDevelopment` and adapters from existing confirmed feeds. + +Deliverables: + +- Stable typed development ids and correlations. +- Subject, other, theater, before/after state, outcome, and confidence. +- Idempotency for repeated observations. +- Dual-write to existing memory and the new story store. +- Debug dump showing raw observation -> development. + +Start with relationship, birth, death, role, founding/home, war, plot, and combat outcome events. + +Exit gate: deterministic scenarios produce correct developments without changing camera behavior. + +### Phase 2 - Character state, consequences, and chapter synthesis + +Build the durable character-centric memory before changing the director. + +Deliverables: + +- `CharacterStoryState` store independent of roster membership. +- `CharacterConsequence` creation from confirmed developments. +- Thread-aware chapter consolidation. +- New Legacy builder behind a presentation flag. +- Migration or graceful fallback for existing session memory. + +Exit gate: + +- No scoreboard/theater-only line appears in new Legacy. +- Each displayed line has a character role and supported consequence. +- Related facts consolidate without losing important names or outcomes. +- The supplied child and battle examples render without duplication or archived scoreboard prose. + +### Phase 3 - Narrative threads + +Create and update durable threads from developments. + +Deliverables: + +- Thread open/update/dormant/resolve rules. +- Protagonist and cast-role assignment. +- Phase transitions based on developments. +- Central-question and pressure evidence models. +- Multiple simultaneous threads per character. +- Thread merge/split rules for overlapping war, rivalry, family, and grief evidence. +- Thread debug board visible to the harness. + +Exit gate: + +- Threads persist across unrelated events and roster churn. +- Timeout cannot falsely resolve a thread. +- Concurrent threads do not steal developments from one another. +- Every phase change cites its triggering development. + +### Phase 4 - Structured Beat and Context prose + +Replace string concatenation with `NarrativeBeatModel` rendering. + +Deliverables: + +- Perspective selection. +- Semantic redundancy checks. +- Beat renderer by development class. +- Context renderer from active thread pressure/question. +- Honest fallbacks when only partial evidence exists. +- Existing Identity and status-banner behavior retained. + +Exit gate: + +- No `child ... · their child`-style duplication. +- Beat, Context, and Identity never repeat the same semantic fact. +- No raw ids or internal terms appear. +- Prose remains correct when camera focus is a supporting cast member. + +### Phase 5 - Story scheduler in shadow mode + +Run the new scheduler without giving it camera ownership. + +Deliverables: + +- Thread scoring and episode proposal. +- Episode information goals. +- Explicit interrupt classification. +- Proposed-shot trace compared with actual event-first shots. +- Story-potential scoring for active, roster, and emerging characters. + +Exit gate: offline/shadow traces demonstrate higher continuity and payoff coverage without hiding +world-critical events. + +### Phase 6 - Story-first camera ownership + +Enable the new scheduler behind the feature flag. + +Deliverables: + +- Episode selection and lifecycle. +- Shot selection within the active story. +- World-critical interrupt and evidence-based resume behavior. +- Quiet-time grounding shots. +- Old `InterestDirector` camera mechanics reused through a narrower shot request interface. +- Only one camera-selection owner at runtime. + +Exit gate: + +- The camera remains on causally related material through an episode. +- Ordinary unrelated events do not thrash the story. +- Critical events still cut in promptly. +- Resume occurs only for an unmet goal or new development. +- Focus and orange prose always describe the same perspective. + +### Phase 7 - MC roster and event-policy retuning + +Retune admission and observation policy after story-first selection is working. + +Deliverables: + +- Story-potential and momentum-driven emerging admission. +- Active ensemble separate from the ten-slot roster. +- Thread-aware retirement and reintroduction. +- Narrative-function classification for high-value event families. +- Reduced camera eligibility for texture/noise that does not change state. + +Exit gate: ordinary characters with coherent developing lives can become MCs without first becoming +leaders or prolonged combat spectacles. + +### Phase 8 - Cutover and deletion (complete) + +Remove the compatibility core only after the new director passes the release gate. + +Delete or simplify: + +- Story ownership paths whose only purpose was manufacturing continuity after event selection. +- Hard-arc headline-to-memory recording. +- Obsolete causal heat and roster bias used only by event-first ranking. +- Redundant prose paths. +- Old feature flag after a stable soak period. + +Retain event strength where it still models urgency, visual value, or interrupt class. + +Exit gate: story-first is the sole production path, with no dual ownership or duplicate memory +writes. + +## Proposed module layout + +Names are provisional; responsibilities are the important part. + +```text +IdleSpectator/Narrative/ + NarrativeDevelopment.cs + NarrativeDevelopmentRouter.cs + CharacterStoryState.cs + CharacterStoryStore.cs + NarrativeThread.cs + NarrativeThreadStore.cs + ThreadRules/ + FamilyThreadRules.cs + PowerThreadRules.cs + ConflictThreadRules.cs + SurvivalThreadRules.cs + JourneyThreadRules.cs + CharacterConsequence.cs + ChapterSynthesizer.cs + StoryPotential.cs + StoryScheduler.cs + EpisodePlan.cs + EpisodeShotSelector.cs + InterruptPolicy.cs + NarrativeBeatModel.cs + NarrativeProse.cs + NarrativeTelemetry.cs +``` + +Existing feed modules should call thin adapters or emit developments through the router. They should +not open threads, author chapters, or choose camera shots themselves. + +## Quality metrics + +Track metrics over deterministic runs and long soaks: + +### Continuity + +- Share of shots causally related to the active episode +- Unexplained cut rate +- Average useful developments per episode +- Episode abandonment rate +- Evidence-based resume rate + +### Story coverage + +- Setup-to-outcome coverage +- Turning points shown +- Consequences shown after outcomes +- Open high-potential threads never covered +- Emerging-character admission rate + +### Prose + +- Semantic duplication rate +- Beat/Context contradiction rate +- Perspective mismatch rate +- Raw-id/internal-term leakage +- Unsupported-claim rate + +### Legacy + +- Consequence-bearing line share +- Theater-only/scoreboard line count, target zero +- Duplicate chapter rate +- Relationship and identity change retention +- Chapter consolidation rate + +### Camera health + +- Empty or invalid focus rate +- Critical interrupt latency +- Thrash rate +- Unpresentable selected shot rate + +Metrics guide tuning; they do not replace qualitative review of complete generated lives. + +## Release gate + +Story-first becomes the default only when all of the following are true: + +1. Deterministic life scenarios produce coherent threads, shots, and Legacy chapters. +2. Multi-thread correlation tests show no cross-thread mutation. +3. No observed Legacy line is a copied scoreboard or generic theater headline. +4. Orange Beat and Context are semantically non-redundant. +5. Critical world events remain visible without ordinary-event thrashing. +6. A long soak shows the same MCs developing across multiple meaningful beats. +7. Emerging non-notable characters can become MCs through story momentum. +8. Camera focus, perspective, Beat, and Context remain aligned. +9. Unsupported narrative claims remain at zero in the audited scenarios. +10. Story-first wins side-by-side qualitative review against the event-first baseline. + +## Risks and controls + +### Over-narrating weak evidence + +Control: every thread transition, context claim, and consequence cites development evidence and a +confidence policy. Prefer omission over invented motive or outcome. + +### Staying too long on a dull story + +Control: information goals, development freshness, dormancy, and coverage debt replace unconditional +holds. Episodes end when no new information remains. + +### Missing the wider world + +Control: explicit world-critical interrupts and a bounded world-context budget. + +### Excessive special cases + +Control: shared development, thread, consequence, and phase contracts. Thread-family rules specialize +only causal interpretation, not camera or UI ownership. + +### Double systems becoming permanent + +Control: define phase-8 deletion targets now. Dual-write and shadow scheduling are migration tools +with explicit exit gates. + +### Performance on large worlds + +Control: update threads from emitted developments rather than rescanning every actor. Amortize roster +discovery, cap active thread evaluation, and keep dormant histories indexed but cold. + +## First vertical slice + +Implement family/lineage as the first end-to-end slice because it exposes the current orange +duplication, requires relationship perspective, has clear state changes, and produces understandable +Legacy consequences without depending on combat scoring. + +Scope: + +1. Seeking is optional setup evidence, not a chapter. +2. Bond formation opens or advances a relationship thread. +3. Child birth emits parent- and child-perspective developments. +4. The renderer produces one non-redundant Beat. +5. Context can express first-child or lineage continuation only when supported. +6. Bond loss or death transforms/resolves the relationship thread. +7. Legacy consolidates the relationship into one or two meaningful chapters. +8. The scheduler shadows the current camera and proposes family episode shots. + +Acceptance story: + +```text +Character A seeks a partner. +A and B form a bond. +They have child C. +B dies during an observed war. +A survives and later forms a new home. +``` + +Expected results: + +- The birth Beat names C once and identifies the relationship naturally. +- Context reflects a supported open pressure, not a generic slogan. +- B's death is correlated with the relationship and war when evidence permits. +- A's Legacy reads as a life sequence, not five independent event notifications. +- The proposed episode shots remain centered on A, B, C, and their relevant theater. + +After this slice is sound, repeat the same architecture for founding/power and rivalry/war before +granting the scheduler camera ownership. + +## Definition of done + +The rewrite is complete when IdleSpectator can follow an initially ordinary character through +multiple causally connected changes, return to that life when something genuinely develops, explain +each selected beat without redundancy, and leave behind a short Legacy that reads as that +character's biography rather than a record of camera labels. diff --git a/docs/story-planner.md b/docs/story-planner.md index e266557..fcf8f1e 100644 --- a/docs/story-planner.md +++ b/docs/story-planner.md @@ -255,12 +255,12 @@ Catalog policy ids live under `event-catalog.json` → `story` ## Files -- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis -- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps +- `IdleSpectator/Narrative/StoryScheduler.cs` - narrative episode and camera-story ownership +- `IdleSpectator/Story/StoryPlanner.cs` - visual completion, cold-hook, spine, and crisis lifecycle +- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + emerging-story admission - `IdleSpectator/Story/SagaProse.cs` - player-facing relation/stake/title English - `IdleSpectator/Story/CaptionComposer.cs` - Identity + Beat + Context - `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - rail Prefer + hover - `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state -- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat - `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels - `IdleSpectator/WatchCaption.cs` - character beat caption + ContextLine diff --git a/scripts/soak-audit-player-log.sh b/scripts/soak-audit-player-log.sh index c18053f..3d8d140 100755 --- a/scripts/soak-audit-player-log.sh +++ b/scripts/soak-audit-player-log.sh @@ -82,9 +82,10 @@ drops = [] bads = [] scenes = [] states = [] +first_active_line = -1 last = None -for ln in lines: +for line_index, ln in enumerate(lines): if harness_noise(ln): continue if "IdleSpectator" not in ln: @@ -105,6 +106,8 @@ for ln in lines: scenes.append(ln.split("Scene end", 1)[1].strip()) elif "[STATE]" in ln: states.append(ln) + if first_active_line < 0 and re.search(r"idle=True", ln): + first_active_line = line_index if last is not None and last.get("unit") is None and "unit=" in ln: last["unit"] = ln.split("unit=", 1)[1].strip() @@ -113,6 +116,7 @@ for d in drops: drop_kinds[d.split(":", 1)[0].strip()] += 1 focus_true = focus_false = 0 +idle_true = idle_false = 0 for s in states: m = re.search(r"focus=(True|False)", s) if not m: @@ -121,6 +125,12 @@ for s in states: focus_false += 1 else: focus_true += 1 + idle = re.search(r"idle=(True|False)", s) + if idle: + if idle.group(1) == "True": + idle_true += 1 + else: + idle_false += 1 def bucket(t: str) -> str: tl = t.lower() @@ -150,6 +160,7 @@ def bucket(t: str) -> str: return "other" buckets = Counter(bucket(t) for t in watching) +bucket_sequence = [bucket(t) for t in watching] scene_reasons = Counter() for s in scenes: m = re.match(r"\(([^)]+)\)", s) @@ -186,6 +197,9 @@ print() print(f"Watching cuts: {len(watching)}") print(f"Focus BADs: {no_focus_bads} (all BADs={len(bads)})") print(f"focus True/False: {focus_true}/{focus_false}") +print(f"idle True/False: {idle_true}/{idle_false}") +if first_active_line >= 0: + print(f"first active state: line {first_active_line + 1}/{len(lines)}") print(f"identity_filter: {identity}") print(f"unpresentable: {unpresentable}") print(f"placeholder tips: {placeholder}") @@ -228,23 +242,52 @@ genesis_intent = buckets.get("decision_genesis_intent", 0) duel_n = buckets.get("combat_duel", 0) multi_n = buckets.get("combat_multi", 0) combat_total = duel_n + multi_n + buckets.get("combat_other", 0) -print(f"Variety: duel={duel_n} multi={multi_n} combat_total={combat_total} genesis_intent={genesis_intent}") +combat_share = combat_total / len(watching) if watching else 0.0 +max_combat_run = 0 +combat_run = 0 +for kind in bucket_sequence: + if kind.startswith("combat_"): + combat_run += 1 + max_combat_run = max(max_combat_run, combat_run) + else: + combat_run = 0 +relationship_n = buckets.get("relationship", 0) +unique_tips = len(set(watching)) +print( + f"Variety: duel={duel_n} multi={multi_n} combat_total={combat_total} " + f"combat_share={combat_share:.1%} max_combat_run={max_combat_run} " + f"relationship={relationship_n} unique_tips={unique_tips}/{len(watching)} " + f"genesis_intent={genesis_intent}" +) if genesis_intent: print(" NOTE: decision_genesis_intent tips should be 0 after 0.28.28 (meta outcomes only).") if combat_total >= 4 and multi_n == 0 and duel_n >= 3: print(" NOTE: combat tips are Duel-heavy; multi scrap may be scarce in this world.") +if first_active_line >= 0 and len(lines) > 0 and first_active_line > len(lines) * 0.25: + print(" NOTE: Idle Spectator activated late; most of this audit window is not a directed soak.") +if len(watching) >= 20 and combat_share > 0.35: + print(" NOTE: combat exceeds the story-first release target of 35%.") +if max_combat_run > 3: + print(" NOTE: more than three consecutive combat cuts; inspect critical bypasses and episode boundaries.") print() +editorial_pacing_ok = len(watching) < 20 or combat_share <= 0.35 +sample_valid = bool(watching) and idle_true > 0 and first_active_line >= 0 ok = ( - no_focus_bads == 0 + sample_valid + and no_focus_bads == 0 and focus_false == 0 + and idle_false == 0 and identity == 0 and placeholder == 0 and len(veg) == 0 and len(mismatches) == 0 and len(sleep_lag) == 0 and genesis_intent == 0 + and editorial_pacing_ok ) +if not sample_valid: + print(" NOTE: no valid active spectator sample was captured.") print("VERDICT:", "PASS" if ok else "REVIEW") sys.exit(0 if ok else 1) PY