From 2293274d790992ba8179433fc2edfaf1e848e8f6 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 23 Jul 2026 19:43:29 -0500 Subject: [PATCH] feat(event): enhance event handling and narrative presentation probes - Introduce new probes for framing approval and family coalescing - Implement exact presentation replay checks to prevent duplicate events - Refactor InterestVariety to manage exact replay cooldowns effectively - Update EventPresentability to improve candidate approval logic - Enhance InterestFeeds to streamline event registration and labeling --- IdleSpectator/AgentHarness.cs | 36 +- IdleSpectator/CameraDirector.cs | 63 ++ IdleSpectator/EventLivePipelinesHarness.cs | 16 +- IdleSpectator/Events/EventPresentability.cs | 79 ++- IdleSpectator/Events/EventReason.cs | 195 +++++- IdleSpectator/Events/Feeds/InterestFeeds.cs | 55 +- .../Events/Feeds/StatusOutbreakFeed.cs | 14 +- IdleSpectator/IdleHitchProbe.cs | 7 + IdleSpectator/InterestCandidate.cs | 19 + .../InterestDirector.StickyMaintain.cs | 2 +- .../InterestDirector.SwitchPolicy.cs | 5 + IdleSpectator/InterestVariety.cs | 186 +++++- .../Narrative/EpisodeShotSelector.cs | 164 +++++- .../Narrative/NarrativeEventStore.cs | 50 ++ .../Narrative/NarrativeExposureLedger.cs | 14 +- IdleSpectator/Narrative/NarrativeModels.cs | 14 + .../Narrative/NarrativeStoryStore.cs | 553 +++++++++++++++++- IdleSpectator/UnitDossier.cs | 37 +- IdleSpectator/WatchCaption.cs | 6 +- 19 files changed, 1418 insertions(+), 97 deletions(-) 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(); }