diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs index 6410e93..2072138 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Decision.cs @@ -166,6 +166,7 @@ public static partial class EventCatalog || s.Contains("fireworks") || s.Contains("put_out_fire") || s.Contains("burn_tumors") + || s.Contains("sexual_reproduction") || s.Contains("asexual_reproduction") || s.Contains("take_city_item") || s.Contains("try_to_steal") diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index 6bc4d38..4c7274a 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -683,6 +683,17 @@ public static class InterestFeeds long sid = SafeId(subject); long rid = SafeId(related); + bool relationshipMilestone = + milestoneKey.Equals("milestone_friend", StringComparison.OrdinalIgnoreCase) + || milestoneKey.Equals("milestone_lover", StringComparison.OrdinalIgnoreCase); + // Chronicle rebuilds can observe one side of a relationship before the partner has + // finished loading. Do not publish a two-person sentence until both visual owners exist. + if (relationshipMilestone + && (related == null || !related.isAlive() || rid == 0)) + { + return; + } + string cameraLabel = label ?? milestoneKey; if (milestoneKey.Equals("milestone_kill", StringComparison.OrdinalIgnoreCase)) { diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 33e4fc1..07fbe06 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -2801,7 +2801,8 @@ internal static class HarnessScenarios } /// - /// Live DeferredInterestFeed EmitDecision path cools intent class (not only harness inject). + /// Reproduction intent is not a story beat. The live DeferredInterestFeed path must + /// reject it before registry intake and leave the camera free for confirmed outcomes. /// private static List StoryDecisionIntentLive() { @@ -2820,20 +2821,11 @@ internal static class HarnessScenarios Step("sdl9", "interest_end_session"), Step("sdl10", "interest_variety_clear"), Step("sdl11", "interest_story_clear"), - Step("sdl20", "decision_emit", expect: "sexual_reproduction_try"), - Step("sdl21", "director_run", wait: 1.2f), - Step("sdl22", "assert", expect: "tip_contains", value: "try to reproduce"), - Step("sdl23", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"), - Step("sdl24", "assert", expect: "beat_cooled", value: "decision_find_lover"), - Step("sdl25", "interest_end_session"), - Step("sdl26", "interest_story_clear"), - Step("sdl30", "decision_emit", expect: "find_lover"), - Step("sdl31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action", - expect: "sdl_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"), - Step("sdl32", "director_run", wait: 1.2f), - Step("sdl33", "assert", expect: "tip_contains", value: "ClearPeer"), - Step("sdl34", "assert", expect: "tip_not_contains", value: "reproduce"), - Step("sdl35", "assert", expect: "tip_not_contains", value: "find a lover"), + Step("sdl20", "assert", expect: "decision_worthy", + value: "sexual_reproduction_try", label: "false"), + Step("sdl21", "decision_emit", expect: "sexual_reproduction_try"), + Step("sdl22", "director_run", wait: 1.2f), + Step("sdl23", "assert", expect: "tip_not_contains", value: "reproduce"), Step("sdl90", "fast_timing", value: "false"), Step("sdl99", "snapshot"), }; diff --git a/IdleSpectator/Narrative/NarrativeExposureLedger.cs b/IdleSpectator/Narrative/NarrativeExposureLedger.cs index 7d67fad..8ca5ed7 100644 --- a/IdleSpectator/Narrative/NarrativeExposureLedger.cs +++ b/IdleSpectator/Narrative/NarrativeExposureLedger.cs @@ -21,13 +21,13 @@ public static class NarrativeExposureLedger new Dictionary(); private static readonly Dictionary Threads = new Dictionary(StringComparer.Ordinal); - private static readonly List PresentedCuts = new List(6); + private static readonly List PresentedCuts = new List(9); private static int _maxCharacterCuts; private static int _maxThreadCuts; private static int _combatRun; - private const int PresentedWindow = 6; - private const int CombatWindowBudget = 2; - private const int CombatRunBudget = 2; + private const int PresentedWindow = 9; + private const int CombatWindowBudget = 3; + private const int CombatRunBudget = 3; public static void Clear() { @@ -90,9 +90,10 @@ public static class NarrativeExposureLedger } /// - /// Two combat cuts may establish and escalate a conflict; another story family must - /// then receive space. Criticality affects which combat cut wins, not whether combat - /// may exceed the global story-first exposure budget. + /// Three combat cuts may establish, escalate, and resolve a conflict; another story + /// family must then receive space. The rolling window also holds combat to one third + /// of authored cuts. Criticality affects which combat cut wins, not whether combat may + /// exceed the global story-first exposure budget. /// public static bool MayPresent(InterestCandidate candidate) { @@ -150,20 +151,34 @@ public static class NarrativeExposureLedger }; NotePresented(combat); NotePresented(combat); + NotePresented(combat); bool routineBlocked = !MayPresent(combat); bool criticalBounded = !MayPresent(criticalCombat); - for (int i = 0; i < 5; i++) NotePresented(family); + for (int i = 0; i < PresentedWindow; i++) NotePresented(family); bool routineReopened = MayPresent(combat); + Clear(); + var activityCombat = new InterestCandidate + { + Completion = InterestCompletionKind.ActivityActive, + Category = "Combat", + Label = "Aro is fighting Bex", + EventStrength = 70f + }; + NotePresented(activityCombat); + NotePresented(activityCombat); + NotePresented(activityCombat); + bool activityCombatBounded = !MayPresent(activityCombat); bool ok = underCovered > prolific && repetition > 0f && diverse == 0f && routineBlocked && criticalBounded - && routineReopened; + && routineReopened + && activityCombatBounded; detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0") + " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0") + " combat=" + routineBlocked + "/" + criticalBounded - + "/" + routineReopened + + "/" + routineReopened + "/" + activityCombatBounded + " pass=" + ok; Clear(); return ok; @@ -196,18 +211,33 @@ public static class NarrativeExposureLedger } } - private static bool IsCombat(InterestCandidate candidate) + internal static bool IsCombat(InterestCandidate candidate) { - return candidate != null - && (candidate.Completion == InterestCompletionKind.CombatActive - || candidate.Completion == InterestCompletionKind.WarFront - || string.Equals( - candidate.AssetId, - "live_combat", - StringComparison.OrdinalIgnoreCase) - || string.Equals( - candidate.AssetId, - "live_battle", - StringComparison.OrdinalIgnoreCase)); + if (candidate == null) + { + return false; + } + + if (candidate.Completion == InterestCompletionKind.CombatActive + || candidate.Completion == InterestCompletionKind.WarFront + || string.Equals(candidate.Category, "Combat", StringComparison.OrdinalIgnoreCase) + || string.Equals(candidate.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase) + || string.Equals(candidate.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(candidate.Verb) + && candidate.Verb.IndexOf("fight", StringComparison.OrdinalIgnoreCase) >= 0)) + { + return true; + } + + string label = candidate.Label ?? ""; + return label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) + || label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase) + || label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) + || label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) + || label.IndexOf(" fighting ", StringComparison.OrdinalIgnoreCase) >= 0 + || label.IndexOf(" in a fight", StringComparison.OrdinalIgnoreCase) >= 0 + || (label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0 + && !label.StartsWith("War -", StringComparison.OrdinalIgnoreCase) + && !label.StartsWith("Plot -", StringComparison.OrdinalIgnoreCase)); } } diff --git a/IdleSpectator/Narrative/NarrativeStoryStore.cs b/IdleSpectator/Narrative/NarrativeStoryStore.cs index 29795be..db9eb48 100644 --- a/IdleSpectator/Narrative/NarrativeStoryStore.cs +++ b/IdleSpectator/Narrative/NarrativeStoryStore.cs @@ -14,9 +14,15 @@ public static class NarrativeStoryStore private const int DevelopmentsPerThread = 16; private const int MaxRuntimeEvents = 2200; private const int MaxRuntimeDevelopments = 2000; - private const int MaxRuntimeThreads = 650; + private const int MaxRuntimeThreads = 512; private const int MaxRuntimeConsequences = 1200; private const int MaxRuntimeCharacters = 2000; + // Saga/episode consequences are retained independently. Anonymous history competes + // for this smaller editorial budget so it cannot pin one character per world event. + private const int MaxAmbientRuntimeConsequences = 192; + // Character states referenced by retained story material are always kept. Beyond + // those, retain only a small bench of strong emerging challengers. + private const int MaxEmergingRuntimeCharacters = 64; private const float RuntimeCompactionIntervalSeconds = 60f; internal const int SchedulerCandidateLimit = 96; internal const int SchedulerCopyLimit = 128; @@ -736,6 +742,10 @@ public static class NarrativeStoryStore AddExistingIds(state.OpenThreadIds, Threads, retainedThreadIds); AddExistingIds(state.ResolvedThreadIds, Threads, retainedThreadIds); } + // Snapshot the authored spine before ambient challengers fill the runtime budget. + // Consequences belonging to these threads remain lossless. + var protectedThreadIds = + new HashSet(retainedThreadIds, StringComparer.Ordinal); ThreadScratch.Clear(); foreach (NarrativeThread thread in Threads.Values) @@ -775,8 +785,10 @@ public static class NarrativeStoryStore var rankedConsequences = new List(Consequences.Values); rankedConsequences.Sort((a, b) => { - bool aProtected = a != null && protectedCharacters.Contains(a.CharacterId); - bool bProtected = b != null && protectedCharacters.Contains(b.CharacterId); + bool aProtected = IsProtectedConsequence( + a, protectedCharacters, protectedThreadIds); + bool bProtected = IsProtectedConsequence( + b, protectedCharacters, protectedThreadIds); if (aProtected != bProtected) return aProtected ? -1 : 1; int importance = (b?.Importance ?? float.MinValue).CompareTo( a?.Importance ?? float.MinValue); @@ -786,26 +798,38 @@ public static class NarrativeStoryStore }); var retainedConsequenceIds = new HashSet(StringComparer.Ordinal); + int protectedConsequences = 0; + int ambientConsequences = 0; for (int i = 0; i < rankedConsequences.Count && retainedConsequenceIds.Count < MaxRuntimeConsequences; i++) { CharacterConsequence consequence = rankedConsequences[i]; - if (consequence == null || string.IsNullOrEmpty(consequence.Id) - || string.IsNullOrEmpty(consequence.DevelopmentId) - || !Developments.ContainsKey(consequence.DevelopmentId)) - { - continue; - } - if (!retainedDevelopmentIds.Contains(consequence.DevelopmentId) - && retainedDevelopmentIds.Count >= MaxRuntimeDevelopments) + bool protectedConsequence = IsProtectedConsequence( + consequence, protectedCharacters, protectedThreadIds); + if (!protectedConsequence + && ambientConsequences >= MaxAmbientRuntimeConsequences) { continue; } - retainedDevelopmentIds.Add(consequence.DevelopmentId); - retainedConsequenceIds.Add(consequence.Id); + if (!TryRetainConsequence( + consequence, + retainedDevelopmentIds, + retainedConsequenceIds)) + { + continue; + } + + if (protectedConsequence) + { + protectedConsequences++; + } + else + { + ambientConsequences++; + } } var rankedDevelopments = new List(Developments.Values); @@ -875,11 +899,6 @@ public static class NarrativeStoryStore { if (consequence == null) continue; if (consequence.CharacterId != 0) retainedCharacterIds.Add(consequence.CharacterId); - if (consequence.OtherId != 0 - && retainedCharacterIds.Count < MaxRuntimeCharacters) - { - retainedCharacterIds.Add(consequence.OtherId); - } } var rankedCharacters = new List(Characters.Values); @@ -892,9 +911,12 @@ public static class NarrativeStoryStore return (b?.LastMeaningfulChangeAt ?? float.MinValue).CompareTo( a?.LastMeaningfulChangeAt ?? float.MinValue); }); + int emergingLimit = Math.Min( + MaxRuntimeCharacters, + retainedCharacterIds.Count + MaxEmergingRuntimeCharacters); for (int i = 0; i < rankedCharacters.Count - && retainedCharacterIds.Count < MaxRuntimeCharacters; + && retainedCharacterIds.Count < emergingLimit; i++) { CharacterStoryState state = rankedCharacters[i]; @@ -918,6 +940,7 @@ public static class NarrativeStoryStore + " developments=" + beforeDevelopments + "->" + Developments.Count + " threads=" + beforeThreads + "->" + Threads.Count + " consequences=" + beforeConsequences + "->" + Consequences.Count + + " consequenceMix=" + protectedConsequences + "/" + ambientConsequences + " characters=" + beforeCharacters + "->" + Characters.Count + " ms=" + LastCompactionMs.ToString("0.0")); return true; @@ -954,6 +977,45 @@ public static class NarrativeStoryStore + development.Confidence * 5f; } + private static bool IsProtectedConsequence( + CharacterConsequence consequence, + HashSet protectedCharacters, + HashSet protectedThreadIds) + { + if (consequence == null) + { + return false; + } + + return (protectedCharacters != null + && (protectedCharacters.Contains(consequence.CharacterId) + || (consequence.OtherId != 0 + && protectedCharacters.Contains(consequence.OtherId)))) + || (protectedThreadIds != null + && !string.IsNullOrEmpty(consequence.ThreadId) + && protectedThreadIds.Contains(consequence.ThreadId)); + } + + private static bool TryRetainConsequence( + CharacterConsequence consequence, + HashSet retainedDevelopmentIds, + HashSet retainedConsequenceIds) + { + if (consequence == null + || string.IsNullOrEmpty(consequence.Id) + || string.IsNullOrEmpty(consequence.DevelopmentId) + || !Developments.ContainsKey(consequence.DevelopmentId) + || (!retainedDevelopmentIds.Contains(consequence.DevelopmentId) + && retainedDevelopmentIds.Count >= MaxRuntimeDevelopments)) + { + return false; + } + + retainedDevelopmentIds.Add(consequence.DevelopmentId); + retainedConsequenceIds.Add(consequence.Id); + return true; + } + private static void AddExistingId( string id, Dictionary source, @@ -1212,6 +1274,7 @@ public static class NarrativeStoryStore const int importedDevelopments = 2300; const int importedThreads = 900; + const int importedConsequences = 1300; for (int i = 0; i < importedDevelopments; i++) { string developmentId = "probe:compact:development:" + i; @@ -1259,6 +1322,18 @@ public static class NarrativeStoryStore thread.AddCast(i + 1, "protagonist"); ImportThread(thread); } + if (i < importedConsequences) + { + ImportConsequence(new CharacterConsequence + { + Id = "probe:compact:consequence:" + i, + CharacterId = i + 1, + DevelopmentId = developmentId, + Kind = CharacterConsequenceKind.FoundHome, + OccurredAt = i + 1, + Importance = 50f + }); + } } bool compacted = CompactRuntimeGraph(force: true); @@ -1278,13 +1353,23 @@ public static class NarrativeStoryStore && Threads.Count <= MaxRuntimeThreads && Consequences.Count <= MaxRuntimeConsequences && Characters.Count <= MaxRuntimeCharacters; + bool leanCharacters = + Characters.Count <= MaxRuntimeThreads + + MaxAmbientRuntimeConsequences + + MaxEmergingRuntimeCharacters; + bool leanConsequences = + Consequences.Count <= MaxAmbientRuntimeConsequences; bool ok = ambientGraphDeferred && authoredBirthProjected && authoredHomeProjected - && compacted && bounded && graphClosed && newestThreadKept; + && compacted && bounded && leanCharacters && leanConsequences + && graphClosed && newestThreadKept; detail = "events=" + NarrativeEventStore.Count + "/" + MaxRuntimeEvents + " developments=" + Developments.Count + "/" + MaxRuntimeDevelopments + " threads=" + Threads.Count + "/" + MaxRuntimeThreads + " consequences=" + Consequences.Count + "/" + MaxRuntimeConsequences + + " ambientBudget=" + MaxAmbientRuntimeConsequences + " characters=" + Characters.Count + "/" + MaxRuntimeCharacters + + " leanCharacters=" + leanCharacters + + " leanConsequences=" + leanConsequences + " newest=" + newestThreadKept + " closed=" + graphClosed + " deferred=" + ambientGraphDeferred diff --git a/IdleSpectator/Narrative/StoryScheduler.cs b/IdleSpectator/Narrative/StoryScheduler.cs index aabb728..e3acb33 100644 --- a/IdleSpectator/Narrative/StoryScheduler.cs +++ b/IdleSpectator/Narrative/StoryScheduler.cs @@ -574,10 +574,7 @@ public static class StoryScheduler 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)); + return NarrativeExposureLedger.IsCombat(candidate); } internal static string SemanticFamily(InterestCandidate candidate) diff --git a/IdleSpectator/event-catalog.json b/IdleSpectator/event-catalog.json index 86424a6..ace0c40 100644 --- a/IdleSpectator/event-catalog.json +++ b/IdleSpectator/event-catalog.json @@ -1824,7 +1824,7 @@ "id": "laughing", "strength": 34, "category": "StatusAmbient", - "camera": true, + "camera": false, "extendsGrief": false }, {