diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 113c8fa..57d7065 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -2892,6 +2892,14 @@ public static class AgentHarness
break;
}
+ case "framing_approval_probe":
+ {
+ bool ok = EventPresentability.HarnessProbeFramingApproval(out string detail);
+ if (ok) _cmdOk++; else _cmdFail++;
+ Emit(cmd, ok, detail: detail);
+ break;
+ }
+
case "interest_story_clear":
StoryPlanner.Clear();
_cmdOk++;
@@ -2944,6 +2952,10 @@ public static class AgentHarness
bool spineOk = WatchCaption.HarnessProbeStorySpineRelevance(out string spine);
bool combatOk = StickyScoreboard.HarnessProbeReasonStability(out string combat);
bool noveltyOk = InterestVariety.HarnessProbeRoutineReplay(out string novelty);
+ bool familyCoalescingOk =
+ InterestVariety.HarnessProbeFamilyCoalescing(out string familyCoalescing);
+ bool nameBoundaryOk =
+ UnitDossier.HarnessProbeNameBoundaries(out string nameBoundary);
bool participantOk =
NarrativePresentationFactory.HarnessProbeNamedParticipantFallback(
out string participant);
@@ -2958,6 +2970,12 @@ public static class AgentHarness
NarrativeStoryStore.HarnessProbeCombatLifecycle(out string lifecycle);
bool schedulerBoundsOk =
NarrativeStoryStore.HarnessProbeSchedulerBounds(out string schedulerBounds);
+ bool runtimeCompactionOk =
+ NarrativeStoryStore.HarnessProbeRuntimeCompaction(out string runtimeCompaction);
+ bool exactPresentationOk =
+ CameraDirector.HarnessProbeExactPresentationReplay(out string exactPresentation);
+ bool framingApprovalOk =
+ EventPresentability.HarnessProbeFramingApproval(out string framingApproval);
bool exposureOk =
NarrativeExposureLedger.HarnessProbeBalance(out string exposure);
bool interruptOk =
@@ -2966,8 +2984,12 @@ public static class AgentHarness
NarrativePersistence.HarnessProbeGraphCompaction(out string graph);
bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence);
bool ok = contextOk && spineOk && combatOk && noveltyOk
+ && familyCoalescingOk && nameBoundaryOk
&& participantOk && loverOk && identityOk && episodeOk
&& proseOk && lifecycleOk && schedulerBoundsOk
+ && runtimeCompactionOk
+ && exactPresentationOk
+ && framingApprovalOk
&& exposureOk && interruptOk
&& graphOk && persistenceOk;
if (ok) _cmdOk++; else _cmdFail++;
@@ -2976,10 +2998,15 @@ public static class AgentHarness
ok,
detail: "context={" + context + "} spine={" + spine
+ "} combat={" + combat + "} novelty={" + novelty
+ + "} familyCoalescing={" + familyCoalescing
+ + "} nameBoundary={" + nameBoundary
+ "} participant={" + participant + "} lover={" + lover
+ "} identity={" + identity + "} episode={" + episode
+ "} prose={" + prose + "} lifecycle={" + lifecycle
+ "} schedulerBounds={" + schedulerBounds
+ + "} runtimeCompaction={" + runtimeCompaction
+ + "} exactPresentation={" + exactPresentation
+ + "} framingApproval={" + framingApproval
+ "} exposure={" + exposure + "} interrupt={" + interrupt
+ "} graph={" + graph
+ "} persistence={" + persistence + "}");
@@ -4446,8 +4473,13 @@ public static class AgentHarness
detail: "schedulerMs=" + StoryScheduler.LastTickMs.ToString("0.00")
+ " candidates=" + StoryScheduler.LastCandidateCount
+ "/" + NarrativeStoryStore.SchedulerCopyLimit
+ + " graphEvents=" + NarrativeEventStore.Count
+ " graphThreads=" + NarrativeStoryStore.ThreadCount
- + " developments=" + NarrativeStoryStore.DevelopmentCount);
+ + " developments=" + NarrativeStoryStore.DevelopmentCount
+ + " consequences=" + NarrativeStoryStore.ConsequenceCount
+ + " characters=" + NarrativeStoryStore.CharacterCount
+ + " compact=" + NarrativeStoryStore.CompactionCount
+ + "/" + NarrativeStoryStore.LastCompactionMs.ToString("0.0") + "ms");
break;
}
@@ -7214,7 +7246,7 @@ public static class AgentHarness
}
///
- /// Force a sticky StatusOutbreak session: Outbreak - Status (n).
+ /// Force a sticky StatusOutbreak session: Outbreak - Status.
/// value = status id (default cursed). Seeds live cursed carriers so maintain recounts stick.
///
private static void DoInterestOutbreakSession(HarnessCommand cmd)
diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs
index 62b087d..9f53592 100644
--- a/IdleSpectator/CameraDirector.cs
+++ b/IdleSpectator/CameraDirector.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using NeoModLoader.services;
using UnityEngine;
@@ -9,6 +10,9 @@ public static class CameraDirector
public static string LastWatchLabel { get; private set; } = "";
public static string LastWatchAssetId { get; private set; } = "";
public static string LastFormattedWatchTip { get; private set; } = "";
+ private const float ExactPresentationCooldownSeconds = 90f;
+ private static readonly Dictionary PresentedLabels =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
public static void FocusUnit(Actor actor, bool updateCaption = true)
{
@@ -34,6 +38,16 @@ public static class CameraDirector
return;
}
+ string incomingLabel = (interest.Label ?? "").Trim();
+ if (!string.IsNullOrEmpty(incomingLabel)
+ && !string.Equals(incomingLabel, LastWatchLabel, StringComparison.Ordinal)
+ && WasPresentedRecently(incomingLabel, Time.unscaledTime))
+ {
+ // Sticky maintain paths may reassert a prior label without going through
+ // candidate selection. Do not turn that refresh into a new camera/caption cut.
+ return;
+ }
+
Actor follow = ResolveFollowUnit(interest);
if (follow != null && follow.isAlive())
{
@@ -90,10 +104,59 @@ public static class CameraDirector
// Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only.
if (tipChanged && !framingOnly)
{
+ NotePresentedLabel(label, Time.unscaledTime);
LogService.LogInfo($"[IdleSpectator] {tip}");
}
}
+ private static bool WasPresentedRecently(string label, float now)
+ {
+ string key = EventReason.ReplayStructureKey(label);
+ return !string.IsNullOrEmpty(key)
+ && PresentedLabels.TryGetValue(key, out float shownAt)
+ && now - shownAt >= 0f
+ && now - shownAt < ExactPresentationCooldownSeconds;
+ }
+
+ private static void NotePresentedLabel(string label, float now)
+ {
+ string key = EventReason.ReplayStructureKey(label);
+ if (string.IsNullOrEmpty(key)) return;
+ PresentedLabels[key] = now;
+ if (PresentedLabels.Count <= 128) return;
+ var expired = new List();
+ foreach (KeyValuePair entry in PresentedLabels)
+ {
+ if (now - entry.Value >= ExactPresentationCooldownSeconds * 2f)
+ {
+ expired.Add(entry.Key);
+ }
+ }
+ for (int i = 0; i < expired.Count; i++) PresentedLabels.Remove(expired[i]);
+ }
+
+ public static void ClearReplayLedger()
+ {
+ PresentedLabels.Clear();
+ }
+
+ public static bool HarnessProbeExactPresentationReplay(out string detail)
+ {
+ PresentedLabels.Clear();
+ NotePresentedLabel("Duel - Aro vs Bex", 10f);
+ bool sameBlocked = WasPresentedRecently("Duel - Aro vs Bex", 20f);
+ bool reframedBlocked =
+ WasPresentedRecently("Mass - Bex (4) vs Aro (7)", 20f);
+ bool otherOpen = !WasPresentedRecently("Duel - Aro vs Cid", 20f);
+ bool expired = !WasPresentedRecently("Duel - Aro vs Bex", 101f);
+ bool ok = sameBlocked && reframedBlocked && otherOpen && expired;
+ detail = "same=" + sameBlocked + " reframed=" + reframedBlocked
+ + " other=" + otherOpen
+ + " expired=" + expired + " pass=" + ok;
+ PresentedLabels.Clear();
+ return ok;
+ }
+
private static Actor ResolveFollowUnit(InterestEvent interest)
{
if (interest == null)
diff --git a/IdleSpectator/EventLivePipelinesHarness.cs b/IdleSpectator/EventLivePipelinesHarness.cs
index 8a0ba16..5d9b24b 100644
--- a/IdleSpectator/EventLivePipelinesHarness.cs
+++ b/IdleSpectator/EventLivePipelinesHarness.cs
@@ -254,7 +254,13 @@ public static class EventLivePipelinesHarness
}
InterestDropLog.Clear();
- InterestRegistry.ForceExpireContaining("worldlog:" + id, Time.unscaledTime);
+ string expectedKey = string.Equals(
+ id,
+ "disaster_earthquake",
+ StringComparison.OrdinalIgnoreCase)
+ ? "earthquake:active"
+ : "worldlog:" + id;
+ InterestRegistry.ForceExpireContaining(expectedKey, Time.unscaledTime);
try
{
if (!TryPostWorldLog(id, unit))
@@ -264,10 +270,14 @@ public static class EventLivePipelinesHarness
continue;
}
- if (InterestRegistry.CountKeysContaining("worldlog:" + id) > 0)
+ if (InterestRegistry.CountKeysContaining(expectedKey) > 0)
{
EventLiveCoverageLedger.Claim(
- "worldLog", id, "id", "WorldLogMessage.add", "busy_bypass=ForceLiveEventFeeds");
+ "worldLog",
+ id,
+ "id",
+ "WorldLogMessage.add",
+ "busy_bypass=ForceLiveEventFeeds;key=" + expectedKey);
}
else if (DropMentions("createsInterest=false", id))
{
diff --git a/IdleSpectator/Events/EventPresentability.cs b/IdleSpectator/Events/EventPresentability.cs
index 301b8e2..e99d99f 100644
--- a/IdleSpectator/Events/EventPresentability.cs
+++ b/IdleSpectator/Events/EventPresentability.cs
@@ -94,8 +94,8 @@ public static class EventPresentability
///
/// Stamp the exact identity that passed the intake gate. Repeated scheduler/director
- /// checks can trust this fingerprint without rescanning the world; any later mutation
- /// naturally invalidates it.
+ /// checks can trust this fingerprint without rescanning the world. Ownership changes
+ /// invalidate it; explicitly recognized live framing updates retain approval.
///
public static void MarkApproved(InterestCandidate scene)
{
@@ -111,16 +111,71 @@ public static class EventPresentability
scene.HasApprovedPresentation = true;
}
- public static bool IsApproved(InterestCandidate scene) =>
- scene != null
- && scene.HasApprovedPresentation
- && scene.ApprovedPresentationSubjectId == scene.SubjectId
- && scene.ApprovedPresentationRelatedId == scene.RelatedId
- && scene.ApprovedPresentationLeadKind == scene.LeadKind
- && string.Equals(
- scene.ApprovedPresentationLabel,
- scene.Label ?? "",
- StringComparison.Ordinal);
+ public static bool IsApproved(InterestCandidate scene)
+ {
+ if (scene == null
+ || !scene.HasApprovedPresentation
+ || scene.ApprovedPresentationSubjectId != scene.SubjectId
+ || scene.ApprovedPresentationRelatedId != scene.RelatedId
+ || scene.ApprovedPresentationLeadKind != scene.LeadKind)
+ {
+ return false;
+ }
+
+ string approved = scene.ApprovedPresentationLabel ?? "";
+ string current = scene.Label ?? "";
+ if (string.Equals(approved, current, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ // Sticky scene maintenance may refresh scale/tier without changing ownership.
+ // Re-running UnitDossier's stranger scan across a developed world for every
+ // headcount tick made the scheduler proposal stage hitch badly.
+ bool liveFraming =
+ scene.Completion == InterestCompletionKind.CombatActive
+ || scene.Completion == InterestCompletionKind.WarFront
+ || scene.Completion == InterestCompletionKind.StatusOutbreak
+ || scene.Completion == InterestCompletionKind.FamilyPack;
+ if (!liveFraming)
+ {
+ return false;
+ }
+
+ if (EventReason.IsHeadcountOnlyChange(approved, current))
+ {
+ return true;
+ }
+
+ return scene.Completion == InterestCompletionKind.CombatActive
+ && EventReason.IsCombatFramingOnlyChange(approved, current);
+ }
+
+ public static bool HarnessProbeFramingApproval(out string detail)
+ {
+ var scene = new InterestCandidate
+ {
+ SubjectId = 41,
+ RelatedId = 42,
+ LeadKind = InterestLeadKind.EventLed,
+ Completion = InterestCompletionKind.CombatActive,
+ Label = "Battle - Alpha (8) vs Beta (4)"
+ };
+ MarkApproved(scene);
+ scene.Label = "Mass - Beta (3) vs Alpha (12)";
+ bool framingKept = IsApproved(scene);
+ scene.Label = "Mass - Gamma (3) vs Alpha (12)";
+ bool foreignCampRejected = !IsApproved(scene);
+ scene.Label = "Mass - Beta (3) vs Alpha (12)";
+ scene.SubjectId = 99;
+ bool ownerMutationRejected = !IsApproved(scene);
+ bool ok = framingKept && foreignCampRejected && ownerMutationRejected;
+ detail = "framing=" + framingKept
+ + " foreignCamp=" + foreignCampRejected
+ + " ownerMutation=" + ownerMutationRejected
+ + " pass=" + ok;
+ return ok;
+ }
private static void TrimCache()
{
diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs
index 0dbc816..a7ce476 100644
--- a/IdleSpectator/Events/EventReason.cs
+++ b/IdleSpectator/Events/EventReason.cs
@@ -61,6 +61,25 @@ public static class EventReason
return SameCombatTheaterCamps(prevKey, nextKey);
}
+ ///
+ /// Stable player-facing replay identity. Combat tiers, side order, and live
+ /// headcounts are framing, not new story developments.
+ ///
+ public static string ReplayStructureKey(string label)
+ {
+ if (string.IsNullOrEmpty(label))
+ {
+ return "";
+ }
+
+ if (TryCombatCampKey(label, out string combatKey))
+ {
+ return "combat:" + combatKey;
+ }
+
+ return label.Trim().ToLowerInvariant();
+ }
+
private static bool SameCombatTheaterCamps(string prevKey, string nextKey)
{
if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b))
@@ -140,7 +159,10 @@ public static class EventReason
return false;
}
- string rest = StripParenCounts(t.Substring(dash + 3).Trim());
+ string rest = StripParenCounts(t.Substring(dash + 3).Trim())
+ .Replace(" ()", "")
+ .Replace("()", "")
+ .Trim();
int vs = rest.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase);
if (vs < 0)
{
@@ -714,19 +736,12 @@ public static class EventReason
return an + " changes status";
}
- // Prose often already includes a verb phrase ("is burning").
- if (p.StartsWith("is ", StringComparison.OrdinalIgnoreCase)
- || p.StartsWith("are ", StringComparison.OrdinalIgnoreCase))
- {
- return an + " " + p;
- }
-
- return an + " · " + p;
+ return SubjectLedClause(an, p);
}
///
- /// Sticky status outbreak orange reason: Outbreak - Cursed (4).
- /// Collective status label + carrier count; never unit proper names.
+ /// Stable sticky status outbreak reason, e.g. Outbreak - Cursed.
+ /// Counts remain in the typed ensemble but do not churn the orange story sentence.
///
public static string StatusOutbreak(LiveEnsemble ensemble)
{
@@ -741,12 +756,12 @@ public static class EventReason
return "";
}
+ string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)
+ ? ensemble.SideA.Key.Substring("status:".Length)
+ : "";
string label = ensemble.SideA.Display ?? "";
if (string.IsNullOrEmpty(label) || LiveEnsemble.IsStatusOutbreakSideKey(label))
{
- string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)
- ? ensemble.SideA.Key.Substring("status:".Length)
- : "";
label = LiveEnsemble.StatusDisplayName(statusId);
}
@@ -767,9 +782,29 @@ public static class EventReason
label = "Afflicted";
}
- label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
int n = Math.Max(0, ensemble.SideA.Count);
- return "Outbreak - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
+ return StatusClusterLabel(statusId, label, n);
+ }
+
+ internal static string StatusClusterLabel(string statusId, string display, int count)
+ {
+ string id = (statusId ?? "").Trim().ToLowerInvariant();
+ if (id == "burning" || id == "burned" || id == "on_fire")
+ {
+ return "Fire spreads through the crowd";
+ }
+ if (id == "drowning")
+ {
+ return "A crowd is drowning";
+ }
+
+ string label = (display ?? "").Trim();
+ if (string.IsNullOrEmpty(label))
+ {
+ label = "Afflicted";
+ }
+ label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
+ return "Outbreak - " + label;
}
public static string Trait(Actor a, string traitId, bool gained)
@@ -1086,6 +1121,87 @@ public static class EventReason
: "Building completed: " + (string.IsNullOrEmpty(id) ? "structure" : id);
}
+ /// Turn killer-POV Chronicle shorthand into a camera-readable subject-led beat.
+ public static string SubjectLedKill(string subjectName, string chronicleLine)
+ {
+ string subject = (subjectName ?? "").Trim();
+ string line = (chronicleLine ?? "").Trim();
+ if (string.IsNullOrEmpty(subject))
+ {
+ return line;
+ }
+ if (line.StartsWith("Killed ", StringComparison.OrdinalIgnoreCase))
+ {
+ return subject + " kills " + line.Substring("Killed ".Length);
+ }
+ return string.IsNullOrEmpty(line) ? subject + " takes a life" : subject + " · " + line;
+ }
+
+ /// Turn friend-POV Chronicle shorthand into a camera-readable subject-led beat.
+ public static string SubjectLedFriend(string subjectName, string chronicleLine)
+ {
+ string subject = (subjectName ?? "").Trim();
+ string line = (chronicleLine ?? "").Trim();
+ if (string.IsNullOrEmpty(subject))
+ {
+ return line;
+ }
+ if (line.StartsWith("Befriended ", StringComparison.OrdinalIgnoreCase))
+ {
+ return subject + " befriends " + line.Substring("Befriended ".Length);
+ }
+ return string.IsNullOrEmpty(line)
+ ? subject + " forms a friendship"
+ : subject + " · " + line;
+ }
+
+ /// Turn lover-POV Chronicle shorthand into a complete relationship beat.
+ public static string SubjectLedLover(string subjectName, string chronicleLine)
+ {
+ const string prefix = "Became lovers with ";
+ string subject = (subjectName ?? "").Trim();
+ string line = (chronicleLine ?? "").Trim();
+ if (string.IsNullOrEmpty(subject))
+ {
+ return line;
+ }
+ if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ string partner = line.Substring(prefix.Length).Trim();
+ if (!string.IsNullOrEmpty(partner))
+ {
+ return subject + " and " + partner + " become lovers";
+ }
+ }
+ return SubjectLedClause(subject, line);
+ }
+
+ ///
+ /// Turn a POV-style clause into a complete orange story sentence.
+ /// Chronicle and happiness sources sometimes omit the subject or capitalize
+ /// the verb as though it were a standalone history entry.
+ ///
+ public static string SubjectLedClause(string subjectName, string clause)
+ {
+ string subject = (subjectName ?? "").Trim();
+ string line = (clause ?? "").Trim();
+ if (string.IsNullOrEmpty(subject))
+ {
+ return line;
+ }
+ if (string.IsNullOrEmpty(line))
+ {
+ return subject + " experiences a change";
+ }
+ if (line.StartsWith(subject, StringComparison.OrdinalIgnoreCase))
+ {
+ return line;
+ }
+
+ return subject + " " + char.ToLowerInvariant(line[0])
+ + (line.Length > 1 ? line.Substring(1) : "");
+ }
+
private static string NormalizeBuildingDisplay(string buildingId, string display)
{
string raw = (buildingId ?? "").Trim().ToLowerInvariant();
@@ -1094,12 +1210,28 @@ public static class EventReason
for (int i = 0; i < cultureSuffixes.Length; i++)
{
string suffix = "_" + cultureSuffixes[i];
- if (!raw.EndsWith(suffix, StringComparison.Ordinal))
+ int suffixAt = raw.LastIndexOf(suffix, StringComparison.Ordinal);
+ if (suffixAt < 0)
{
continue;
}
- string baseId = raw.Substring(0, raw.Length - suffix.Length);
+ string variant = raw.Substring(suffixAt + suffix.Length);
+ bool validVariant = string.IsNullOrEmpty(variant);
+ if (!validVariant && variant[0] == '_')
+ {
+ validVariant = variant.Length > 1;
+ for (int j = 1; j < variant.Length && validVariant; j++)
+ {
+ validVariant = char.IsDigit(variant[j]);
+ }
+ }
+ if (!validVariant)
+ {
+ continue;
+ }
+
+ string baseId = raw.Substring(0, suffixAt);
string fallback = HumanizeId(raw);
if (string.IsNullOrEmpty(value)
|| value.Equals(fallback, StringComparison.OrdinalIgnoreCase))
@@ -1133,12 +1265,35 @@ public static class EventReason
string building = BuildingComplete(null, "tent_human", spectacle: false);
bool buildingClean = building == "Building completed: Tent"
&& building.IndexOf("Human", StringComparison.OrdinalIgnoreCase) < 0;
+ string variantBuilding = BuildingComplete(null, "windmill_elf_0", spectacle: false);
+ bool variantBuildingClean = variantBuilding == "Building completed: Windmill"
+ && variantBuilding.IndexOf("Elf", StringComparison.OrdinalIgnoreCase) < 0
+ && variantBuilding.IndexOf("0", StringComparison.Ordinal) < 0;
+ string kill = SubjectLedKill("Aro", "Killed Tampoo (monkey)");
+ bool killClean = kill == "Aro kills Tampoo (monkey)";
+ string friend = SubjectLedFriend("Eceas", "Befriended Oreero");
+ bool friendClean = friend == "Eceas befriends Oreero";
+ string lover = SubjectLedLover("Starna", "Became lovers with Skenstyr");
+ bool loverClean = lover == "Starna and Skenstyr become lovers";
+ string clause = SubjectLedClause("Tampoo", "Is starving");
+ bool clauseClean = clause == "Tampoo is starving";
+ string fire = StatusClusterLabel("burning", "Burning", 3);
+ string drowning = StatusClusterLabel("drowning", "Drowning", 8);
+ string outbreak = StatusClusterLabel("cursed", "Cursed", 12);
+ bool fireClean = fire == "Fire spreads through the crowd";
+ bool hazardClean = drowning == "A crowd is drowning"
+ && outbreak == "Outbreak - Cursed";
string species = LiveEnsemble.SpeciesDisplay("slava");
string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay("Slava");
bool collectiveClean = string.Equals(
species, kingdomAsSpecies, StringComparison.OrdinalIgnoreCase);
- bool ok = buildingClean && collectiveClean;
- detail = "building='" + building + "' species='" + species
+ bool ok = buildingClean && variantBuildingClean && killClean && friendClean
+ && loverClean && clauseClean && fireClean && hazardClean && collectiveClean;
+ detail = "building='" + building + "' variant='" + variantBuilding
+ + "' kill='" + kill + "' friend='" + friend + "' lover='" + lover
+ + "' clause='" + clause
+ + "' fire='" + fire + "' drowning='" + drowning
+ + "' outbreak='" + outbreak + "' species='" + species
+ "' kingdomSpecies='" + kingdomAsSpecies + "' pass=" + ok;
return ok;
}
diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs
index f92403e..af7f981 100644
--- a/IdleSpectator/Events/Feeds/InterestFeeds.cs
+++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs
@@ -160,24 +160,40 @@ public static class InterestFeeds
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
string kingdom = message.special1 ?? "";
- string key = "worldlog:" + assetId + ":" + kingdom;
+ bool earthquake = string.Equals(
+ assetId,
+ "disaster_earthquake",
+ StringComparison.OrdinalIgnoreCase);
+ // Earthquake.startQuake and its WorldLog are two observations of one event.
+ // Give both the same durable card so the player never sees
+ // "Earthquake shakes the land" immediately followed by bare "Earthquake".
+ string key = earthquake
+ ? "earthquake:active"
+ : "worldlog:" + assetId + ":" + kingdom;
float boost = PeekCivicBoost(kingdom, assetId);
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
string label = entry.MakeLabel(message);
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
{
+ if (earthquake)
+ {
+ label = EventReason.Earthquake(follow);
+ }
EventFeedUtil.Register(
key,
- "worldlog",
+ earthquake ? "earthquake" : "worldlog",
category,
label,
- assetId,
+ earthquake ? "earthquake" : assetId,
evtSeed + boost,
pos,
follow,
minWatch: entry.MinWatch,
- maxWatch: entry.MaxWatch);
+ maxWatch: earthquake ? 45f : entry.MaxWatch,
+ completion: earthquake
+ ? InterestCompletionKind.EarthquakeActive
+ : InterestCompletionKind.FixedDwell);
return;
}
@@ -186,16 +202,19 @@ public static class InterestFeeds
{
EventFeedUtil.Register(
key,
- "worldlog",
+ earthquake ? "earthquake" : "worldlog",
category,
- label,
- assetId,
+ earthquake ? "Earthquake shakes the land" : label,
+ earthquake ? "earthquake" : assetId,
evtSeed + boost,
position,
follow: null,
locationOnly: true,
minWatch: entry.MinWatch,
- maxWatch: entry.MaxWatch);
+ maxWatch: earthquake ? 45f : entry.MaxWatch,
+ completion: earthquake
+ ? InterestCompletionKind.EarthquakeActive
+ : InterestCompletionKind.FixedDwell);
}
}
@@ -657,6 +676,19 @@ public static class InterestFeeds
long sid = SafeId(subject);
long rid = SafeId(related);
+ string cameraLabel = label ?? milestoneKey;
+ if (milestoneKey.Equals("milestone_kill", StringComparison.OrdinalIgnoreCase))
+ {
+ cameraLabel = EventReason.SubjectLedKill(SafeName(subject), cameraLabel);
+ }
+ else if (milestoneKey.Equals("milestone_friend", StringComparison.OrdinalIgnoreCase))
+ {
+ cameraLabel = EventReason.SubjectLedFriend(SafeName(subject), cameraLabel);
+ }
+ else if (milestoneKey.Equals("milestone_lover", StringComparison.OrdinalIgnoreCase))
+ {
+ cameraLabel = EventReason.SubjectLedLover(SafeName(subject), cameraLabel);
+ }
// Shared key space with happiness canonical merges.
string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid;
var candidate = new InterestCandidate
@@ -672,7 +704,7 @@ public static class InterestFeeds
RelatedUnit = related,
SubjectId = sid,
RelatedId = rid,
- Label = label ?? milestoneKey,
+ Label = cameraLabel,
// Milestone id owns AssetId so kill/lover/friend crumbs cool as a class
// (species id used to land every hunter tip on arc:asset:elf).
AssetId = milestoneKey,
@@ -831,7 +863,10 @@ public static class InterestFeeds
}
else if (!string.IsNullOrEmpty(occ.PlainClause))
{
- label = occ.SubjectName + " " + occ.PlainClause;
+ string subjectName = string.IsNullOrWhiteSpace(occ.SubjectName)
+ ? EventFeedUtil.SafeName(subject)
+ : occ.SubjectName.Trim();
+ label = EventReason.SubjectLedClause(subjectName, occ.PlainClause);
}
else
{
diff --git a/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
index b02e2ae..e5906e4 100644
--- a/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
+++ b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs
@@ -62,7 +62,16 @@ public static class StatusOutbreakFeed
return;
}
- ApplySticky(registered, ensemble, statusId, pos);
+ // A group is a different story object from the unit's onset. Publish one
+ // canonical outbreak card instead of mutating every per-unit status key
+ // into an identical crowd card. The latter left several changing cards in
+ // the queue and forced repeated full-world presentation validation.
+ if (TryRegisterOutbreak(subject, statusId, entry))
+ {
+ InterestRegistry.ForceExpireContaining(
+ "status:" + statusId + ":",
+ Time.unscaledTime);
+ }
}
private static float _lastTickAt = -999f;
@@ -498,5 +507,8 @@ public static class StatusOutbreakFeed
registered.Label = EventReason.StatusOutbreak(ensemble);
registered.ParticipantCount = ensemble.ParticipantCount;
InterestScoring.ScoreCheap(registered);
+ // ApplySticky runs after registry intake. Its generated crowd framing is
+ // authoritative and ownership-preserving, so retain that approval.
+ EventPresentability.MarkApproved(registered);
}
}
diff --git a/IdleSpectator/IdleHitchProbe.cs b/IdleSpectator/IdleHitchProbe.cs
index 48a30ab..6160e2f 100644
--- a/IdleSpectator/IdleHitchProbe.cs
+++ b/IdleSpectator/IdleHitchProbe.cs
@@ -167,6 +167,13 @@ public static class IdleHitchProbe
+ $"{StoryScheduler.LastThreadScoreMs:0.0}/"
+ $"{StoryScheduler.LastPendingCopyMs:0.0}/"
+ $"{StoryScheduler.LastProposalMs:0.0} "
+ + $"selectorParts={EpisodeShotSelector.LastReplayFilterMs:0.0}/"
+ + $"{EpisodeShotSelector.LastPresentabilityMs:0.0}/"
+ + $"{EpisodeShotSelector.LastClassificationMs:0.0}/"
+ + $"{EpisodeShotSelector.LastScoringMs:0.0} "
+ + $"selectorPresent={EpisodeShotSelector.LastPresentabilityChecks} "
+ + $"selectorSlow={EpisodeShotSelector.LastSlowCandidateMs:0.0}:"
+ + $"{EpisodeShotSelector.LastSlowCandidateKey} "
+ $"feedParts={InterestFeeds.LastHappinessMs:0.0}/"
+ $"{InterestFeeds.LastScannerMs:0.0}[{InterestFeeds.LastBattleScanMs:0.0},"
+ $"{InterestFeeds.LastWarFeedMs:0.0},{InterestFeeds.LastPlotFeedMs:0.0},"
diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs
index 5550fd2..214cc36 100644
--- a/IdleSpectator/InterestCandidate.cs
+++ b/IdleSpectator/InterestCandidate.cs
@@ -116,6 +116,25 @@ public sealed class InterestCandidate
public string ScoreDetail = "";
public readonly List ParticipantIds = new List(4);
+ // Replay-key memoization. The scheduler consults all three policies repeatedly while
+ // candidates wait in the registry; rebuilding lowercase/prefixed strings every pass
+ // created avoidable garbage-collection hitches in developed worlds.
+ internal bool ReplayKeysCached;
+ internal string ReplayCacheLabel = "";
+ internal string ReplayCacheKey = "";
+ internal string ReplayCacheAssetId = "";
+ internal string ReplayCacheSource = "";
+ internal string ReplayCacheStatusId = "";
+ internal string ReplayCacheHappinessEffectId = "";
+ internal string ReplayCacheActionId = "";
+ internal long ReplayCacheSubjectId;
+ internal InterestCompletionKind ReplayCacheCompletion;
+ internal NarrativeFunction ReplayCacheFunction;
+ internal bool ReplayCacheHasFunction;
+ internal string CachedEditorialReplayKey = "";
+ internal string CachedExactReplayKey = "";
+ internal string CachedRoutineReplayKey = "";
+
// Compat forwarders - existing combat maintain/harness code.
public int CombatPeakParticipants
{
diff --git a/IdleSpectator/InterestDirector.StickyMaintain.cs b/IdleSpectator/InterestDirector.StickyMaintain.cs
index 570b423..d98a071 100644
--- a/IdleSpectator/InterestDirector.StickyMaintain.cs
+++ b/IdleSpectator/InterestDirector.StickyMaintain.cs
@@ -1135,7 +1135,7 @@ public static partial class InterestDirector
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
}
- // Outbreak stickies are groups only - never "Outbreak - X (0)" / solo (1).
+ // Outbreak stickies are groups only; solo carriers remain individual status beats.
int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
if (carriers < 2)
{
diff --git a/IdleSpectator/InterestDirector.SwitchPolicy.cs b/IdleSpectator/InterestDirector.SwitchPolicy.cs
index 8469121..9477f1b 100644
--- a/IdleSpectator/InterestDirector.SwitchPolicy.cs
+++ b/IdleSpectator/InterestDirector.SwitchPolicy.cs
@@ -27,6 +27,11 @@ public static partial class InterestDirector
InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key);
return false;
}
+ if (candidate != _current && InterestVariety.IsExactReplayCooled(candidate))
+ {
+ InterestDropLog.Record("exact_replay", candidate.Label ?? candidate.Key);
+ return false;
+ }
if (candidate != _current
&& !StoryScheduler.MayUseCombatShot(candidate))
diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs
index ddb02f9..008f206 100644
--- a/IdleSpectator/InterestVariety.cs
+++ b/IdleSpectator/InterestVariety.cs
@@ -17,6 +17,7 @@ public static class InterestVariety
public static float FixedDwellBeatCooldownSeconds =>
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f);
+ public static float ExactReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 90f);
public static float RoutineReplayCooldownSeconds =>
Mathf.Max(FixedDwellBeatCooldownSeconds, 120f);
@@ -52,6 +53,7 @@ public static class InterestVariety
LastPickHadBothPools = false;
_lastArcKey = "";
_arcStreak = 0;
+ CameraDirector.ClearReplayLedger();
}
/// Harness: push an arc into the rarity window without a full selection.
@@ -186,6 +188,14 @@ public static class InterestVariety
Time.unscaledTime + RoutineReplayCooldownSeconds);
}
+ string exactKey = ExactReplayKey(chosen);
+ if (!string.IsNullOrEmpty(exactKey))
+ {
+ StampCooldown(
+ exactKey,
+ Time.unscaledTime + ExactReplayCooldownSeconds);
+ }
+
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
}
@@ -211,6 +221,21 @@ public static class InterestVariety
return CooldownUntil.TryGetValue(key, out float until) && now < until;
}
+ ///
+ /// Prevent an identical player-facing sentence from being presented as a fresh cut,
+ /// even when separate hooks generated different candidate keys.
+ ///
+ public static bool IsExactReplayCooled(InterestCandidate c, float now = -1f)
+ {
+ string key = ExactReplayKey(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();
@@ -224,7 +249,7 @@ public static class InterestVariety
var replay = new InterestCandidate
{
Key = "combat:pair:1:2:refresh",
- Label = "Duel - Alpha vs Beta",
+ Label = "Mass - Beta (3) vs Alpha (5)",
Completion = InterestCompletionKind.CombatActive
};
var different = new InterestCandidate
@@ -241,12 +266,39 @@ public static class InterestVariety
StatusId = "sleeping"
};
bool sameCooled = IsEditorialReplayCooled(replay);
+ bool structuralExactCooled = IsExactReplayCooled(replay);
bool differentOpen = !IsEditorialReplayCooled(different);
bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture);
- bool ok = sameCooled && differentOpen && textureUsesBeatPolicy;
+ var life = new InterestCandidate
+ {
+ Key = "relationship:1:2:first",
+ Label = "Norron mates with Onaano",
+ Completion = InterestCompletionKind.FixedDwell
+ };
+ NoteSelection(life, updateMix: false);
+ var lifeReplay = new InterestCandidate
+ {
+ Key = "relationship:1:2:second",
+ Label = "Norron mates with Onaano",
+ Completion = InterestCompletionKind.FixedDwell
+ };
+ var lifeProgress = new InterestCandidate
+ {
+ Key = "relationship:1:2:home",
+ Label = "Norron finds a home",
+ Completion = InterestCompletionKind.FixedDwell
+ };
+ bool exactLifeCooled = IsExactReplayCooled(lifeReplay);
+ bool progressedLifeOpen = !IsExactReplayCooled(lifeProgress);
+ bool ok = sameCooled && structuralExactCooled
+ && differentOpen && textureUsesBeatPolicy
+ && exactLifeCooled && progressedLifeOpen;
detail = "sameCooled=" + sameCooled
+ + " structuralExact=" + structuralExactCooled
+ " differentOpen=" + differentOpen
- + " textureUsesBeatPolicy=" + textureUsesBeatPolicy;
+ + " textureUsesBeatPolicy=" + textureUsesBeatPolicy
+ + " exactLife=" + exactLifeCooled
+ + " progressedLife=" + progressedLifeOpen;
Clear();
return ok;
}
@@ -319,6 +371,43 @@ public static class InterestVariety
return ok;
}
+ public static bool HarnessProbeFamilyCoalescing(out string detail)
+ {
+ Clear();
+ var birthFact = new InterestCandidate
+ {
+ SubjectId = 41,
+ RelatedId = 43,
+ AssetId = "add_child",
+ Completion = InterestCompletionKind.FixedDwell
+ };
+ NoteSelection(birthFact, updateMix: false);
+
+ var sameParentReaction = new InterestCandidate
+ {
+ SubjectId = 41,
+ HappinessEffectId = "just_had_child",
+ AssetId = "just_had_child",
+ Completion = InterestCompletionKind.FixedDwell
+ };
+ var otherParentReaction = new InterestCandidate
+ {
+ SubjectId = 42,
+ HappinessEffectId = "just_had_child",
+ AssetId = "just_had_child",
+ Completion = InterestCompletionKind.FixedDwell
+ };
+
+ bool echoCooled = IsBeatCooled(sameParentReaction);
+ bool unrelatedOpen = !IsBeatCooled(otherParentReaction);
+ bool ok = echoCooled && unrelatedOpen;
+ detail = "echo=" + echoCooled
+ + " unrelated=" + unrelatedOpen
+ + " pass=" + ok;
+ 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.
@@ -795,6 +884,12 @@ public static class InterestVariety
continue;
}
+ if (IsExactReplayCooled(c, now))
+ {
+ InterestDropLog.Record("exact_replay", c.Label ?? c.Key);
+ continue;
+ }
+
if (IsRoutineReplayCooled(c, now))
{
InterestDropLog.Record("routine_replay", c.Label ?? c.Key);
@@ -1008,6 +1103,14 @@ public static class InterestVariety
{
return "";
}
+ EnsureReplayKeys(c);
+ return c.CachedEditorialReplayKey;
+ }
+
+ private static string BuildEditorialReplayKey(
+ InterestCandidate c,
+ string normalizedLabel)
+ {
bool stickyCard = c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront
@@ -1021,22 +1124,37 @@ public static class InterestVariety
return "";
}
- string label = (c.Label ?? "").Trim().ToLowerInvariant();
- if (!string.IsNullOrEmpty(label))
+ if (!string.IsNullOrEmpty(normalizedLabel))
{
- return "replay:label:" + label;
+ return "replay:label:" + normalizedLabel;
}
string key = (c.Key ?? "").Trim().ToLowerInvariant();
return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key;
}
+ private static string ExactReplayKey(InterestCandidate c)
+ {
+ if (c == null)
+ {
+ return "";
+ }
+ EnsureReplayKeys(c);
+ return c.CachedExactReplayKey;
+ }
+
private static string RoutineReplayKey(InterestCandidate c)
{
if (c == null || c.SubjectId == 0)
{
return "";
}
+ EnsureReplayKeys(c);
+ return c.CachedRoutineReplayKey;
+ }
+
+ private static string BuildRoutineReplayKey(InterestCandidate c)
+ {
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
if (function != NarrativeFunction.CharacterTexture
@@ -1057,6 +1175,53 @@ public static class InterestVariety
: "routine:" + c.SubjectId + ":" + family;
}
+ private static void EnsureReplayKeys(InterestCandidate c)
+ {
+ string label = c.Label ?? "";
+ string key = c.Key ?? "";
+ string assetId = c.AssetId ?? "";
+ string source = c.Source ?? "";
+ string statusId = c.StatusId ?? "";
+ string happinessId = c.HappinessEffectId ?? "";
+ string actionId = c.NarrativePresentation?.ActionId ?? "";
+ if (c.ReplayKeysCached
+ && c.ReplayCacheSubjectId == c.SubjectId
+ && c.ReplayCacheCompletion == c.Completion
+ && c.ReplayCacheFunction == c.NarrativeFunction
+ && c.ReplayCacheHasFunction == c.HasNarrativeFunction
+ && string.Equals(c.ReplayCacheLabel, label, StringComparison.Ordinal)
+ && string.Equals(c.ReplayCacheKey, key, StringComparison.Ordinal)
+ && string.Equals(c.ReplayCacheAssetId, assetId, StringComparison.Ordinal)
+ && string.Equals(c.ReplayCacheSource, source, StringComparison.Ordinal)
+ && string.Equals(c.ReplayCacheStatusId, statusId, StringComparison.Ordinal)
+ && string.Equals(
+ c.ReplayCacheHappinessEffectId, happinessId, StringComparison.Ordinal)
+ && string.Equals(c.ReplayCacheActionId, actionId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ c.ReplayKeysCached = true;
+ c.ReplayCacheLabel = label;
+ c.ReplayCacheKey = key;
+ c.ReplayCacheAssetId = assetId;
+ c.ReplayCacheSource = source;
+ c.ReplayCacheStatusId = statusId;
+ c.ReplayCacheHappinessEffectId = happinessId;
+ c.ReplayCacheActionId = actionId;
+ c.ReplayCacheSubjectId = c.SubjectId;
+ c.ReplayCacheCompletion = c.Completion;
+ c.ReplayCacheFunction = c.NarrativeFunction;
+ c.ReplayCacheHasFunction = c.HasNarrativeFunction;
+
+ string normalizedLabel = EventReason.ReplayStructureKey(label);
+ c.CachedEditorialReplayKey = BuildEditorialReplayKey(c, normalizedLabel);
+ c.CachedExactReplayKey = string.IsNullOrEmpty(normalizedLabel)
+ ? ""
+ : "exact:label:" + normalizedLabel;
+ c.CachedRoutineReplayKey = BuildRoutineReplayKey(c);
+ }
+
///
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
///
@@ -1839,6 +2004,15 @@ public static class InterestVariety
yield return "beat:life:love:world";
}
+ // The relationship feed reports the concrete birth first ("gains a child");
+ // happiness can report the same parent's reaction a frame later. They are one
+ // family chapter, so showing the fact suppresses that immediate emotional echo
+ // without cooling unrelated parents or later births world-wide.
+ if (string.Equals(asset, "add_child", StringComparison.OrdinalIgnoreCase))
+ {
+ yield return "beat:life:family:" + subject;
+ }
+
if (EventCatalog.Decision.IsLifeIntentDecision(asset))
{
yield return "beat:asset:" + asset + ":world";
diff --git a/IdleSpectator/Narrative/EpisodeShotSelector.cs b/IdleSpectator/Narrative/EpisodeShotSelector.cs
index d43a766..e116b95 100644
--- a/IdleSpectator/Narrative/EpisodeShotSelector.cs
+++ b/IdleSpectator/Narrative/EpisodeShotSelector.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Diagnostics;
using UnityEngine;
namespace IdleSpectator;
@@ -16,32 +17,90 @@ public sealed class EpisodeShotProposal
/// Selects the best presentable shot that can explain its place in the active episode.
public static class EpisodeShotSelector
{
+ public static float LastReplayFilterMs { get; private set; }
+ public static float LastPresentabilityMs { get; private set; }
+ public static float LastClassificationMs { get; private set; }
+ public static float LastScoringMs { get; private set; }
+ public static int LastPresentabilityChecks { get; private set; }
+ public static string LastSlowCandidateKey { get; private set; } = "";
+ public static float LastSlowCandidateMs { get; private set; }
+
public static EpisodeShotProposal Pick(
IList candidates,
EpisodePlan episode,
NarrativeThread thread,
bool requirePresentable = true)
{
+ ResetTiming();
if (candidates == null || candidates.Count == 0 || episode == null) return null;
- EpisodeShotProposal bestRelated = null;
- EpisodeShotProposal bestInterrupt = null;
+ bool trace = IdleHitchProbe.Enabled;
+ InterestCandidate bestRelatedCandidate = null;
+ NarrativeInterruptClass bestRelatedKind = NarrativeInterruptClass.OrdinaryInteresting;
+ EpisodeShotRole bestRelatedRole = EpisodeShotRole.Unknown;
+ string bestRelatedSemanticKey = "";
+ float bestRelatedScore = float.MinValue;
+
+ InterestCandidate bestInterruptCandidate = null;
+ NarrativeInterruptClass bestInterruptKind = NarrativeInterruptClass.OrdinaryInteresting;
+ EpisodeShotRole bestInterruptRole = EpisodeShotRole.Unknown;
+ float bestInterruptScore = float.MinValue;
+
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)
- || InterestVariety.IsRoutineReplayCooled(c)
- || !StoryScheduler.MayUseCombatShot(episode, c)
- || (requirePresentable && !harness && !EventPresentability.WouldShow(c))) continue;
+ if (c == null || c.Selected) continue;
+
+ long stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
+ bool replayCooled =
+ InterestVariety.IsEditorialReplayCooled(c)
+ || InterestVariety.IsExactReplayCooled(c)
+ || InterestVariety.IsRoutineReplayCooled(c);
+ if (trace)
+ {
+ LastReplayFilterMs += ElapsedMs(stageStarted);
+ }
+ if (replayCooled || !StoryScheduler.MayUseCombatShot(episode, c)) continue;
+
+ if (requirePresentable && !harness)
+ {
+ stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
+ bool presentable = EventPresentability.WouldShow(c);
+ if (trace)
+ {
+ float elapsed = ElapsedMs(stageStarted);
+ LastPresentabilityMs += elapsed;
+ LastPresentabilityChecks++;
+ if (elapsed > LastSlowCandidateMs)
+ {
+ LastSlowCandidateMs = elapsed;
+ LastSlowCandidateKey = c.Key ?? "";
+ }
+ }
+ if (!presentable) continue;
+ }
+
+ stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
- if (function == NarrativeFunction.Noise) continue;
+ if (function == NarrativeFunction.Noise)
+ {
+ if (trace)
+ {
+ LastClassificationMs += ElapsedMs(stageStarted);
+ }
+ continue;
+ }
NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
EpisodeShotRole role = RoleFor(c, function, kind, episode);
- string semanticKey = SemanticKey(c, role);
bool related = kind == NarrativeInterruptClass.RelatedEscalation;
+ bool advances = related && AdvancesEpisode(episode, role);
+ string semanticKey = related && !advances ? SemanticKey(c, role) : "";
+ if (trace)
+ {
+ LastClassificationMs += ElapsedMs(stageStarted);
+ }
if (related
- && !AdvancesEpisode(episode, role)
+ && !advances
&& episode.RecentSemanticKeys.Contains(semanticKey))
{
continue;
@@ -53,36 +112,91 @@ public static class EpisodeShotSelector
continue;
}
+ stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
float score = EditorialScore(c, episode, kind, function, role);
- var proposal = new EpisodeShotProposal
+ if (trace)
{
- Candidate = c,
- InterruptClass = kind,
- EditorialScore = score,
- Role = role,
- SemanticKey = semanticKey,
- Reason = Reason(kind) + ":" + role
- };
+ LastScoringMs += ElapsedMs(stageStarted);
+ }
if (kind == NarrativeInterruptClass.RelatedEscalation)
{
- if (bestRelated == null || score > bestRelated.EditorialScore) bestRelated = proposal;
+ if (bestRelatedCandidate == null || score > bestRelatedScore)
+ {
+ bestRelatedCandidate = c;
+ bestRelatedKind = kind;
+ bestRelatedRole = role;
+ bestRelatedSemanticKey = string.IsNullOrEmpty(semanticKey)
+ ? SemanticKey(c, role)
+ : semanticKey;
+ bestRelatedScore = score;
+ }
}
else if (InterruptPolicy.MayInterrupt(kind)
- && (bestInterrupt == null || score > bestInterrupt.EditorialScore))
+ && (bestInterruptCandidate == null || score > bestInterruptScore))
{
- bestInterrupt = proposal;
+ bestInterruptCandidate = c;
+ bestInterruptKind = kind;
+ bestInterruptRole = role;
+ bestInterruptScore = score;
}
}
// 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))
+ if (bestRelatedCandidate != null
+ && (bestInterruptCandidate == null || bestInterruptScore < bestRelatedScore + 25f))
{
- return bestRelated;
+ return BuildProposal(
+ bestRelatedCandidate,
+ bestRelatedKind,
+ bestRelatedRole,
+ bestRelatedSemanticKey,
+ bestRelatedScore);
}
- return bestInterrupt ?? bestRelated;
+ if (bestInterruptCandidate != null)
+ {
+ return BuildProposal(
+ bestInterruptCandidate,
+ bestInterruptKind,
+ bestInterruptRole,
+ SemanticKey(bestInterruptCandidate, bestInterruptRole),
+ bestInterruptScore);
+ }
+
+ return null;
+ }
+
+ private static void ResetTiming()
+ {
+ LastReplayFilterMs = 0f;
+ LastPresentabilityMs = 0f;
+ LastClassificationMs = 0f;
+ LastScoringMs = 0f;
+ LastPresentabilityChecks = 0;
+ LastSlowCandidateKey = "";
+ LastSlowCandidateMs = 0f;
+ }
+
+ private static float ElapsedMs(long started) =>
+ (float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency);
+
+ private static EpisodeShotProposal BuildProposal(
+ InterestCandidate candidate,
+ NarrativeInterruptClass kind,
+ EpisodeShotRole role,
+ string semanticKey,
+ float score)
+ {
+ return new EpisodeShotProposal
+ {
+ Candidate = candidate,
+ InterruptClass = kind,
+ EditorialScore = score,
+ Role = role,
+ SemanticKey = semanticKey ?? "",
+ Reason = Reason(kind) + ":" + role
+ };
}
private static float EditorialScore(
diff --git a/IdleSpectator/Narrative/NarrativeEventStore.cs b/IdleSpectator/Narrative/NarrativeEventStore.cs
index 91d25e0..a97e611 100644
--- a/IdleSpectator/Narrative/NarrativeEventStore.cs
+++ b/IdleSpectator/Narrative/NarrativeEventStore.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using UnityEngine;
namespace IdleSpectator;
@@ -51,4 +52,53 @@ public static class NarrativeEventStore
Events[record.Id] = record;
return true;
}
+
+ ///
+ /// Keep the event side of the runtime graph aligned with retained developments.
+ /// Recent unprojected observations fill any remaining room so dedupe remains useful.
+ ///
+ internal static int Compact(HashSet retainedDevelopmentIds, int max)
+ {
+ if (Events.Count <= max || max <= 0)
+ {
+ return 0;
+ }
+
+ var retained = new HashSet(StringComparer.Ordinal);
+ var ranked = new List(Events.Values);
+ ranked.Sort((a, b) =>
+ {
+ bool aLinked = a != null
+ && retainedDevelopmentIds != null
+ && retainedDevelopmentIds.Contains(a.DevelopmentId ?? "");
+ bool bLinked = b != null
+ && retainedDevelopmentIds != null
+ && retainedDevelopmentIds.Contains(b.DevelopmentId ?? "");
+ if (aLinked != bLinked) return aLinked ? -1 : 1;
+ float aAt = a?.ObservedAt ?? float.MinValue;
+ float bAt = b?.ObservedAt ?? float.MinValue;
+ int observed = bAt.CompareTo(aAt);
+ if (observed != 0) return observed;
+ return (b?.WorldTime ?? double.MinValue).CompareTo(
+ a?.WorldTime ?? double.MinValue);
+ });
+
+ for (int i = 0; i < ranked.Count && retained.Count < max; i++)
+ {
+ NarrativeEventRecord record = ranked[i];
+ if (record != null && !string.IsNullOrEmpty(record.Id))
+ {
+ retained.Add(record.Id);
+ }
+ }
+
+ int before = Events.Count;
+ var remove = new List();
+ foreach (string id in Events.Keys)
+ {
+ if (!retained.Contains(id)) remove.Add(id);
+ }
+ for (int i = 0; i < remove.Count; i++) Events.Remove(remove[i]);
+ return Mathf.Max(0, before - Events.Count);
+ }
}
diff --git a/IdleSpectator/Narrative/NarrativeExposureLedger.cs b/IdleSpectator/Narrative/NarrativeExposureLedger.cs
index bc0c824..7d67fad 100644
--- a/IdleSpectator/Narrative/NarrativeExposureLedger.cs
+++ b/IdleSpectator/Narrative/NarrativeExposureLedger.cs
@@ -90,12 +90,13 @@ public static class NarrativeExposureLedger
}
///
- /// Two routine combat cuts may establish a conflict; another story family must then
- /// receive space. World-critical combat bypasses this global exposure cap.
+ /// 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.
///
public static bool MayPresent(InterestCandidate candidate)
{
- if (!IsCombat(candidate) || candidate.EventStrength >= 95f)
+ if (!IsCombat(candidate))
{
return true;
}
@@ -150,18 +151,19 @@ public static class NarrativeExposureLedger
NotePresented(combat);
NotePresented(combat);
bool routineBlocked = !MayPresent(combat);
- bool criticalAllowed = MayPresent(criticalCombat);
+ bool criticalBounded = !MayPresent(criticalCombat);
for (int i = 0; i < 5; i++) NotePresented(family);
bool routineReopened = MayPresent(combat);
bool ok = underCovered > prolific
&& repetition > 0f
&& diverse == 0f
&& routineBlocked
- && criticalAllowed
+ && criticalBounded
&& routineReopened;
detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0")
+ " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0")
- + " combat=" + routineBlocked + "/" + criticalAllowed + "/" + routineReopened
+ + " combat=" + routineBlocked + "/" + criticalBounded
+ + "/" + routineReopened
+ " pass=" + ok;
Clear();
return ok;
diff --git a/IdleSpectator/Narrative/NarrativeModels.cs b/IdleSpectator/Narrative/NarrativeModels.cs
index 8d33516..e7a387b 100644
--- a/IdleSpectator/Narrative/NarrativeModels.cs
+++ b/IdleSpectator/Narrative/NarrativeModels.cs
@@ -137,6 +137,8 @@ public sealed class NarrativeCastRole
/// Durable unresolved question centered on one life.
public sealed class NarrativeThread
{
+ private const int MaxRetainedCast = 16;
+
public string Id = "";
public NarrativeThreadKind Kind;
public long ProtagonistId;
@@ -177,6 +179,18 @@ public sealed class NarrativeThread
{
if (id == 0 || HasCast(id)) return;
Cast.Add(new NarrativeCastRole { CharacterId = id, Role = role ?? "" });
+ while (Cast.Count > MaxRetainedCast)
+ {
+ int removeAt = 0;
+ while (removeAt < Cast.Count
+ && Cast[removeAt] != null
+ && Cast[removeAt].CharacterId == ProtagonistId)
+ {
+ removeAt++;
+ }
+ if (removeAt >= Cast.Count) break;
+ Cast.RemoveAt(removeAt);
+ }
}
}
diff --git a/IdleSpectator/Narrative/NarrativeStoryStore.cs b/IdleSpectator/Narrative/NarrativeStoryStore.cs
index 1d04e1d..f2a870d 100644
--- a/IdleSpectator/Narrative/NarrativeStoryStore.cs
+++ b/IdleSpectator/Narrative/NarrativeStoryStore.cs
@@ -11,6 +11,13 @@ public static class NarrativeStoryStore
private const int ConsequencesPerCharacter = 24;
private const int OpenThreadsPerCharacter = 16;
private const int ResolvedThreadsPerCharacter = 24;
+ private const int DevelopmentsPerThread = 16;
+ private const int MaxRuntimeEvents = 2200;
+ private const int MaxRuntimeDevelopments = 2000;
+ private const int MaxRuntimeThreads = 650;
+ private const int MaxRuntimeConsequences = 1200;
+ private const int MaxRuntimeCharacters = 2000;
+ private const float RuntimeCompactionIntervalSeconds = 60f;
internal const int SchedulerCandidateLimit = 96;
internal const int SchedulerCopyLimit = 128;
private static readonly Dictionary Developments =
@@ -33,11 +40,15 @@ public static class NarrativeStoryStore
private static readonly Dictionary> ConflictCastByThread =
new Dictionary>(StringComparer.Ordinal);
private static readonly List IdScratch = new List(SchedulerCandidateLimit);
+ private static float _nextRuntimeCompactionAt;
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 int ConsequenceCount => Consequences.Count;
+ public static float LastCompactionMs { get; private set; }
+ public static int CompactionCount { get; private set; }
public static void Clear()
{
@@ -52,6 +63,9 @@ public static class NarrativeStoryStore
OpenConflictsByCharacter.Clear();
ConflictCastByThread.Clear();
IdScratch.Clear();
+ _nextRuntimeCompactionAt = 0f;
+ LastCompactionMs = 0f;
+ CompactionCount = 0;
NarrativeEventStore.Clear();
Revision++;
StoryScheduler.Clear();
@@ -189,14 +203,19 @@ public static class NarrativeStoryStore
}
Developments[development.Id] = development;
- CharacterStoryState subject = GetOrCreateCharacter(development.SubjectId, development.Subject);
- TouchDevelopment(subject, development);
- if (development.OtherId != 0)
+ if (ShouldProjectDevelopment(development))
{
- GetOrCreateCharacter(development.OtherId, development.Other);
- }
+ CharacterStoryState subject =
+ GetOrCreateCharacter(development.SubjectId, development.Subject);
+ TouchDevelopment(subject, development);
+ if (development.OtherId != 0)
+ {
+ GetOrCreateCharacter(development.OtherId, development.Other);
+ }
- NarrativeThreadRules.Apply(development);
+ NarrativeThreadRules.Apply(development);
+ }
+ MaybeCompactRuntimeGraph();
Revision++;
return true;
}
@@ -236,6 +255,7 @@ public static class NarrativeStoryStore
thread.UpdatedAt = development.OccurredAt;
thread.LatestMeaningfulChangeId = development.Id;
if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id);
+ TrimThreadDevelopmentHistory(thread);
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));
@@ -252,6 +272,18 @@ public static class NarrativeStoryStore
return thread;
}
+ private static void TrimThreadDevelopmentHistory(NarrativeThread thread)
+ {
+ if (thread == null) return;
+ while (thread.DevelopmentIds.Count > DevelopmentsPerThread)
+ {
+ // The opening evidence remains useful for episode/Legacy context. Evict the
+ // oldest middle beat while retaining the newest state transition.
+ int removeAt = thread.DevelopmentIds.Count > 1 ? 1 : 0;
+ thread.DevelopmentIds.RemoveAt(removeAt);
+ }
+ }
+
public static void AddConsequence(CharacterConsequence consequence)
{
if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return;
@@ -494,6 +526,7 @@ public static class NarrativeStoryStore
int repaired = 0;
foreach (NarrativeThread thread in Threads.Values)
{
+ TrimThreadDevelopmentHistory(thread);
if (thread != null
&& thread.Kind == NarrativeThreadKind.Founding
&& thread.IsOpen
@@ -559,6 +592,398 @@ public static class NarrativeStoryStore
return repaired;
}
+ private static void MaybeCompactRuntimeGraph()
+ {
+ bool overHighWater =
+ NarrativeEventStore.Count > MaxRuntimeEvents * 3 / 2
+ || Developments.Count > MaxRuntimeDevelopments * 3 / 2
+ || Threads.Count > MaxRuntimeThreads * 3 / 2
+ || Consequences.Count > MaxRuntimeConsequences * 3 / 2
+ || Characters.Count > MaxRuntimeCharacters * 3 / 2;
+ if (!overHighWater)
+ {
+ return;
+ }
+
+ float now = Time.unscaledTime;
+ bool emergency =
+ NarrativeEventStore.Count > MaxRuntimeEvents * 2
+ || Developments.Count > MaxRuntimeDevelopments * 2
+ || Threads.Count > MaxRuntimeThreads * 2
+ || Consequences.Count > MaxRuntimeConsequences * 2
+ || Characters.Count > MaxRuntimeCharacters * 2;
+ if (!emergency && now < _nextRuntimeCompactionAt)
+ {
+ return;
+ }
+
+ CompactRuntimeGraph(force: false);
+ }
+
+ ///
+ /// Birth evidence remains in the event/development stores for persistence and dedupe.
+ /// A full mirrored lineage graph is useful only once the family touches the authored cast;
+ /// projecting every anonymous newborn was creating hundreds of disposable threads per minute.
+ ///
+ private static bool ShouldProjectDevelopment(NarrativeDevelopment development)
+ {
+ if (development == null
+ || development.Kind != NarrativeDevelopmentKind.ChildBorn
+ || (!string.IsNullOrEmpty(development.EvidenceSource)
+ && development.EvidenceSource.StartsWith(
+ "harness", StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ if (LifeSagaRoster.IsMc(development.SubjectId)
+ || LifeSagaRoster.IsPrefer(development.SubjectId)
+ || LifeSagaRoster.IsMcCast(development.SubjectId)
+ || LifeSagaRoster.IsMc(development.OtherId)
+ || LifeSagaRoster.IsPrefer(development.OtherId)
+ || LifeSagaRoster.IsMcCast(development.OtherId))
+ {
+ return true;
+ }
+
+ EpisodePlan episode = StoryScheduler.ActiveEpisode;
+ if (episode == null)
+ {
+ return false;
+ }
+ if (episode.ProtagonistId == development.SubjectId
+ || episode.ProtagonistId == development.OtherId)
+ {
+ return true;
+ }
+
+ NarrativeThread active = Thread(episode.ThreadId);
+ return active != null
+ && (active.HasCast(development.SubjectId)
+ || active.HasCast(development.OtherId));
+ }
+
+ ///
+ /// Bounds the live narrative graph while protecting the active episode and the
+ /// Saga cast. Persistence already compacts its sidecar; this prevents long-running
+ /// worlds from retaining every anonymous one-shot thread until the next reload.
+ ///
+ private static bool CompactRuntimeGraph(bool force)
+ {
+ bool needsCompaction =
+ NarrativeEventStore.Count > MaxRuntimeEvents
+ || Developments.Count > MaxRuntimeDevelopments
+ || Threads.Count > MaxRuntimeThreads
+ || Consequences.Count > MaxRuntimeConsequences
+ || Characters.Count > MaxRuntimeCharacters;
+ if (!force && !needsCompaction)
+ {
+ return false;
+ }
+
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ int beforeEvents = NarrativeEventStore.Count;
+ int beforeDevelopments = Developments.Count;
+ int beforeThreads = Threads.Count;
+ int beforeConsequences = Consequences.Count;
+ int beforeCharacters = Characters.Count;
+ float now = Time.unscaledTime;
+
+ var protectedCharacters = new HashSet();
+ LifeSagaRoster.CopySlots(RosterScratch);
+ for (int i = 0; i < RosterScratch.Count; i++)
+ {
+ long id = RosterScratch[i]?.UnitId ?? 0;
+ if (id != 0) protectedCharacters.Add(id);
+ }
+ RosterScratch.Clear();
+
+ EpisodePlan episode = StoryScheduler.ActiveEpisode;
+ if (episode != null && episode.ProtagonistId != 0)
+ {
+ protectedCharacters.Add(episode.ProtagonistId);
+ }
+
+ var retainedThreadIds = new HashSet(StringComparer.Ordinal);
+ if (episode != null && !string.IsNullOrEmpty(episode.ThreadId)
+ && Threads.ContainsKey(episode.ThreadId))
+ {
+ retainedThreadIds.Add(episode.ThreadId);
+ }
+
+ foreach (long characterId in protectedCharacters)
+ {
+ CharacterStoryState state = Character(characterId);
+ if (state == null) continue;
+ AddExistingIds(state.OpenThreadIds, Threads, retainedThreadIds);
+ AddExistingIds(state.ResolvedThreadIds, Threads, retainedThreadIds);
+ }
+
+ ThreadScratch.Clear();
+ foreach (NarrativeThread thread in Threads.Values)
+ {
+ if (thread != null && !retainedThreadIds.Contains(thread.Id))
+ {
+ ThreadScratch.Add(thread);
+ }
+ }
+ ThreadScratch.Sort((a, b) =>
+ ThreadRetentionScore(b, now).CompareTo(ThreadRetentionScore(a, now)));
+ for (int i = 0;
+ i < ThreadScratch.Count && retainedThreadIds.Count < MaxRuntimeThreads;
+ i++)
+ {
+ retainedThreadIds.Add(ThreadScratch[i].Id);
+ }
+ ThreadScratch.Clear();
+
+ var retainedDevelopmentIds = new HashSet(StringComparer.Ordinal);
+ foreach (string threadId in retainedThreadIds)
+ {
+ NarrativeThread thread = Thread(threadId);
+ AddExistingId(thread?.LatestMeaningfulChangeId, Developments, retainedDevelopmentIds);
+ }
+ foreach (long characterId in protectedCharacters)
+ {
+ CharacterStoryState state = Character(characterId);
+ if (state == null) continue;
+ AddExistingIds(
+ state.RecentDevelopmentIds,
+ Developments,
+ retainedDevelopmentIds,
+ MaxRuntimeDevelopments);
+ }
+
+ 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);
+ if (aProtected != bProtected) return aProtected ? -1 : 1;
+ int importance = (b?.Importance ?? float.MinValue).CompareTo(
+ a?.Importance ?? float.MinValue);
+ if (importance != 0) return importance;
+ return (b?.OccurredAt ?? float.MinValue).CompareTo(
+ a?.OccurredAt ?? float.MinValue);
+ });
+
+ var retainedConsequenceIds = new HashSet(StringComparer.Ordinal);
+ 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)
+ {
+ continue;
+ }
+
+ retainedDevelopmentIds.Add(consequence.DevelopmentId);
+ retainedConsequenceIds.Add(consequence.Id);
+ }
+
+ var rankedDevelopments = new List(Developments.Values);
+ rankedDevelopments.Sort((a, b) =>
+ {
+ float aScore = DevelopmentRetentionScore(a);
+ float bScore = DevelopmentRetentionScore(b);
+ int score = bScore.CompareTo(aScore);
+ if (score != 0) return score;
+ return (b?.OccurredAt ?? float.MinValue).CompareTo(
+ a?.OccurredAt ?? float.MinValue);
+ });
+ for (int i = 0;
+ i < rankedDevelopments.Count
+ && retainedDevelopmentIds.Count < MaxRuntimeDevelopments;
+ i++)
+ {
+ NarrativeDevelopment development = rankedDevelopments[i];
+ if (development != null && !string.IsNullOrEmpty(development.Id))
+ {
+ retainedDevelopmentIds.Add(development.Id);
+ }
+ }
+
+ RemoveUnretained(Threads, retainedThreadIds);
+ RemoveUnretained(Consequences, retainedConsequenceIds);
+ RemoveUnretained(Developments, retainedDevelopmentIds);
+
+ foreach (NarrativeThread thread in Threads.Values)
+ {
+ if (thread == null) continue;
+ thread.DevelopmentIds.RemoveAll(id => !retainedDevelopmentIds.Contains(id));
+ if (!string.IsNullOrEmpty(thread.LatestMeaningfulChangeId)
+ && retainedDevelopmentIds.Contains(thread.LatestMeaningfulChangeId)
+ && !thread.DevelopmentIds.Contains(thread.LatestMeaningfulChangeId))
+ {
+ thread.DevelopmentIds.Add(thread.LatestMeaningfulChangeId);
+ }
+ if (!retainedDevelopmentIds.Contains(thread.OpenedByDevelopmentId))
+ {
+ thread.OpenedByDevelopmentId = thread.DevelopmentIds.Count > 0
+ ? thread.DevelopmentIds[0]
+ : thread.LatestMeaningfulChangeId;
+ }
+ TrimThreadDevelopmentHistory(thread);
+ }
+
+ foreach (CharacterConsequence consequence in Consequences.Values)
+ {
+ if (consequence != null && !retainedThreadIds.Contains(consequence.ThreadId))
+ {
+ consequence.ThreadId = "";
+ }
+ }
+
+ NarrativeEventStore.Compact(retainedDevelopmentIds, MaxRuntimeEvents);
+
+ var retainedCharacterIds = new HashSet(protectedCharacters);
+ foreach (NarrativeThread thread in Threads.Values)
+ {
+ if (thread != null && thread.ProtagonistId != 0)
+ {
+ retainedCharacterIds.Add(thread.ProtagonistId);
+ }
+ }
+ foreach (CharacterConsequence consequence in Consequences.Values)
+ {
+ 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);
+ rankedCharacters.Sort((a, b) =>
+ {
+ float aScore = EffectiveStoryPotential(a, now);
+ float bScore = EffectiveStoryPotential(b, now);
+ int score = bScore.CompareTo(aScore);
+ if (score != 0) return score;
+ return (b?.LastMeaningfulChangeAt ?? float.MinValue).CompareTo(
+ a?.LastMeaningfulChangeAt ?? float.MinValue);
+ });
+ for (int i = 0;
+ i < rankedCharacters.Count
+ && retainedCharacterIds.Count < MaxRuntimeCharacters;
+ i++)
+ {
+ CharacterStoryState state = rankedCharacters[i];
+ if (state != null && state.CharacterId != 0)
+ {
+ retainedCharacterIds.Add(state.CharacterId);
+ }
+ }
+ RemoveUnretained(Characters, retainedCharacterIds);
+
+ RepairConsistency();
+ Revision++;
+ stopwatch.Stop();
+ LastCompactionMs = (float)stopwatch.Elapsed.TotalMilliseconds;
+ CompactionCount++;
+ _nextRuntimeCompactionAt = now + RuntimeCompactionIntervalSeconds;
+
+ NeoModLoader.services.LogService.LogInfo(
+ "[IdleSpectator][NARRATIVE] runtime compact "
+ + "events=" + beforeEvents + "->" + NarrativeEventStore.Count
+ + " developments=" + beforeDevelopments + "->" + Developments.Count
+ + " threads=" + beforeThreads + "->" + Threads.Count
+ + " consequences=" + beforeConsequences + "->" + Consequences.Count
+ + " characters=" + beforeCharacters + "->" + Characters.Count
+ + " ms=" + LastCompactionMs.ToString("0.0"));
+ return true;
+ }
+
+ private static float ThreadRetentionScore(NarrativeThread thread, float now)
+ {
+ if (thread == null) return float.MinValue;
+ float age = Mathf.Max(0f, now - thread.UpdatedAt);
+ float freshness = Mathf.Max(0f, 60f - age / 10f);
+ float open = thread.IsOpen ? 45f : 0f;
+ float phase = thread.Phase == NarrativePhase.TurningPoint ? 24f
+ : thread.Phase == NarrativePhase.Outcome ? 18f
+ : thread.Phase == NarrativePhase.Consequence ? 14f : 0f;
+ return freshness + open + phase + thread.Urgency * 3f
+ + thread.Momentum + thread.CoverageDebt;
+ }
+
+ private static float DevelopmentRetentionScore(NarrativeDevelopment development)
+ {
+ if (development == null) return float.MinValue;
+ float function;
+ switch (development.Function)
+ {
+ case NarrativeFunction.TurningPoint: function = 60f; break;
+ case NarrativeFunction.Resolution: function = 55f; break;
+ case NarrativeFunction.Consequence: function = 50f; break;
+ case NarrativeFunction.Escalation: function = 40f; break;
+ case NarrativeFunction.Development: function = 30f; break;
+ case NarrativeFunction.ThreadOpener: function = 25f; break;
+ default: function = 0f; break;
+ }
+ return function + development.Magnitude * 0.2f
+ + development.Confidence * 5f;
+ }
+
+ private static void AddExistingId(
+ string id,
+ Dictionary source,
+ HashSet retained)
+ {
+ if (!string.IsNullOrEmpty(id) && source.ContainsKey(id))
+ {
+ retained.Add(id);
+ }
+ }
+
+ private static void AddExistingIds(
+ List ids,
+ Dictionary source,
+ HashSet retained,
+ int max = int.MaxValue)
+ {
+ if (ids == null) return;
+ for (int i = 0; i < ids.Count && retained.Count < max; i++)
+ {
+ AddExistingId(ids[i], source, retained);
+ }
+ }
+
+ private static void RemoveUnretained(
+ Dictionary source,
+ HashSet retained)
+ {
+ var remove = new List();
+ foreach (string id in source.Keys)
+ {
+ if (!retained.Contains(id)) remove.Add(id);
+ }
+ for (int i = 0; i < remove.Count; i++) source.Remove(remove[i]);
+ }
+
+ private static void RemoveUnretained(
+ Dictionary source,
+ HashSet retained)
+ {
+ var remove = new List();
+ foreach (long id in source.Keys)
+ {
+ if (!retained.Contains(id)) remove.Add(id);
+ }
+ for (int i = 0; i < remove.Count; i++) source.Remove(remove[i]);
+ }
+
public static bool HarnessProbeCombatLifecycle(out string detail)
{
Clear();
@@ -697,6 +1122,121 @@ public static class NarrativeStoryStore
return ok;
}
+ public static bool HarnessProbeRuntimeCompaction(out string detail)
+ {
+ Clear();
+ Record(new NarrativeDevelopment
+ {
+ Id = "probe:compact:ambient-child",
+ Kind = NarrativeDevelopmentKind.ChildBorn,
+ Function = NarrativeFunction.Consequence,
+ SubjectId = 9000001,
+ OtherId = 9000002,
+ FamilyKey = "probe:ambient-family",
+ EvidenceSource = "natural_soak",
+ OccurredAt = 1f,
+ Magnitude = 76f
+ });
+ bool ambientBirthDeferred = ThreadCount == 0 && CharacterCount == 0;
+ Record(new NarrativeDevelopment
+ {
+ Id = "probe:compact:authored-child",
+ Kind = NarrativeDevelopmentKind.ChildBorn,
+ Function = NarrativeFunction.Consequence,
+ SubjectId = 9000011,
+ OtherId = 9000012,
+ FamilyKey = "probe:authored-family",
+ EvidenceSource = "harness_compaction",
+ OccurredAt = 2f,
+ Magnitude = 76f
+ });
+ bool authoredBirthProjected = ThreadCount == 2 && CharacterCount == 2;
+ Clear();
+
+ const int importedDevelopments = 2300;
+ const int importedThreads = 900;
+ for (int i = 0; i < importedDevelopments; i++)
+ {
+ string developmentId = "probe:compact:development:" + i;
+ ImportDevelopment(new NarrativeDevelopment
+ {
+ Id = developmentId,
+ Kind = NarrativeDevelopmentKind.HomeGained,
+ Function = i == importedDevelopments - 1
+ ? NarrativeFunction.TurningPoint
+ : NarrativeFunction.CharacterTexture,
+ SubjectId = i + 1,
+ OccurredAt = i + 1,
+ Magnitude = i == importedDevelopments - 1 ? 100f : 5f
+ });
+ NarrativeEventStore.Import(new NarrativeEventRecord
+ {
+ Id = "event:" + developmentId,
+ DevelopmentId = developmentId,
+ SubjectId = i + 1,
+ ObservedAt = i + 1,
+ WorldTime = i + 1
+ });
+ ImportCharacter(new CharacterStoryState
+ {
+ CharacterId = i + 1,
+ LastMeaningfulChangeAt = i + 1,
+ StoryPotential = i % 20
+ });
+ if (i < importedThreads)
+ {
+ var thread = new NarrativeThread
+ {
+ Id = "probe:compact:thread:" + i,
+ Kind = NarrativeThreadKind.Founding,
+ ProtagonistId = i + 1,
+ OpenedByDevelopmentId = developmentId,
+ LatestMeaningfulChangeId = developmentId,
+ Status = NarrativeThreadStatus.Resolved,
+ Phase = NarrativePhase.Outcome,
+ OpenedAt = i + 1,
+ UpdatedAt = i + 1,
+ Momentum = i / 100f
+ };
+ thread.DevelopmentIds.Add(developmentId);
+ thread.AddCast(i + 1, "protagonist");
+ ImportThread(thread);
+ }
+ }
+
+ bool compacted = CompactRuntimeGraph(force: true);
+ bool graphClosed = true;
+ foreach (NarrativeThread thread in Threads.Values)
+ {
+ if (thread == null
+ || Development(thread.LatestMeaningfulChangeId) == null)
+ {
+ graphClosed = false;
+ break;
+ }
+ }
+ bool newestThreadKept = Thread("probe:compact:thread:899") != null;
+ bool bounded = NarrativeEventStore.Count <= MaxRuntimeEvents
+ && Developments.Count <= MaxRuntimeDevelopments
+ && Threads.Count <= MaxRuntimeThreads
+ && Consequences.Count <= MaxRuntimeConsequences
+ && Characters.Count <= MaxRuntimeCharacters;
+ bool ok = ambientBirthDeferred && authoredBirthProjected
+ && compacted && bounded && graphClosed && newestThreadKept;
+ detail = "events=" + NarrativeEventStore.Count + "/" + MaxRuntimeEvents
+ + " developments=" + Developments.Count + "/" + MaxRuntimeDevelopments
+ + " threads=" + Threads.Count + "/" + MaxRuntimeThreads
+ + " consequences=" + Consequences.Count + "/" + MaxRuntimeConsequences
+ + " characters=" + Characters.Count + "/" + MaxRuntimeCharacters
+ + " newest=" + newestThreadKept
+ + " closed=" + graphClosed
+ + " birth=" + ambientBirthDeferred + "/" + authoredBirthProjected
+ + " ms=" + LastCompactionMs.ToString("0.0")
+ + " pass=" + ok;
+ Clear();
+ return ok;
+ }
+
private static void AddSchedulerCopy(List into, NarrativeThread thread)
{
if (thread == null || into.Count >= SchedulerCopyLimit
@@ -852,6 +1392,7 @@ public static class NarrativeStoryStore
{
thread.DevelopmentIds.Add(terminal.Id);
}
+ TrimThreadDevelopmentHistory(thread);
}
if (Characters.TryGetValue(thread.ProtagonistId, out CharacterStoryState state))
diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs
index ae5b200..1160b2c 100644
--- a/IdleSpectator/UnitDossier.cs
+++ b/IdleSpectator/UnitDossier.cs
@@ -841,7 +841,7 @@ public sealed class UnitDossier
}
int start = i;
- while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_' || text[i] == '-'))
+ while (i < text.Length && IsNameTokenChar(text[i]))
{
i++;
}
@@ -1016,9 +1016,9 @@ public sealed class UnitDossier
return false;
}
- bool leftOk = idx == 0 || !char.IsLetterOrDigit(reason[idx - 1]);
+ bool leftOk = idx == 0 || !IsNameTokenChar(reason[idx - 1]);
int end = idx + name.Length;
- bool rightOk = end >= reason.Length || !char.IsLetterOrDigit(reason[end]);
+ bool rightOk = end >= reason.Length || !IsNameTokenChar(reason[end]);
if (leftOk && rightOk)
{
return true;
@@ -1030,6 +1030,37 @@ public sealed class UnitDossier
return false;
}
+ private static bool IsNameTokenChar(char c)
+ {
+ return char.IsLetterOrDigit(c)
+ || c == '_'
+ || c == '-'
+ || c == '\''
+ || c == '\u2019';
+ }
+
+ public static bool HarnessProbeNameBoundaries(out string detail)
+ {
+ bool apostropheSuffixRejected =
+ !ContainsWholeName("O'Ehel decides to try to reproduce", "Ehel");
+ bool curlySuffixRejected =
+ !ContainsWholeName("O\u2019Ehel decides to try to reproduce", "Ehel");
+ bool hyphenSuffixRejected =
+ !ContainsWholeName("Ana-Maria gains a child", "Maria");
+ bool separateNameAccepted =
+ ContainsWholeName("Ulalfa gains a child, Asodahl", "Asodahl");
+ bool ok = apostropheSuffixRejected
+ && curlySuffixRejected
+ && hyphenSuffixRejected
+ && separateNameAccepted;
+ detail = "apostrophe=" + apostropheSuffixRejected
+ + " curly=" + curlySuffixRejected
+ + " hyphen=" + hyphenSuffixRejected
+ + " separate=" + separateNameAccepted
+ + " pass=" + ok;
+ return ok;
+ }
+
private static string EventLabelBeat(string label, UnitDossier d, bool recordDrops = true)
{
if (string.IsNullOrEmpty(label))
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index 0530068..7e3cbb2 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -120,6 +120,7 @@ public static class WatchCaption
private static bool _dossierRowsNeedRestore;
private static float _nextPortraitAt;
private static long _portraitActorId;
+ private const float PortraitRefreshSeconds = 1f;
private static string _statusBanner = "";
private static float _statusBannerUntil;
private static string _lastLoggedCaption = "";
@@ -921,7 +922,8 @@ public static class WatchCaption
long id = EventFeedUtil.SafeId(actor);
float now = Time.unscaledTime;
- // Same unit: tile refresh every frame hitchs; keep the live pose at ~5 Hz.
+ // The vanilla tile refresh can trigger an expensive UI/layout pass. A portrait is
+ // supporting context, not animation-critical, so refresh it at 1 Hz while held.
// Focus change always applies immediately.
if (id == _portraitActorId && now < _nextPortraitAt)
{
@@ -929,7 +931,7 @@ public static class WatchCaption
}
_portraitActorId = id;
- _nextPortraitAt = now + 0.2f;
+ _nextPortraitAt = now + PortraitRefreshSeconds;
DossierAvatar.Show(actor);
BringHeaderFront();
}