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
},
{