From 54476fb7a1cc889382473f4a028c47c95cd287e2 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 23 Jul 2026 17:21:23 -0500 Subject: [PATCH] feat(narrative): enhance narrative probes and combat lifecycle tracking - Introduce new probes for combat lifecycle, scheduler bounds, and exposure - Implement narrative scheduler health probe in harness scenarios - Update InterestCandidate to include approved presentation details - Refactor EventPresentability to manage candidate approval and caching - Improve IdleHitchProbe to track performance metrics during narrative events --- IdleSpectator/AgentHarness.cs | 39 +- IdleSpectator/Events/EventFeedUtil.cs | 16 +- IdleSpectator/Events/EventPresentability.cs | 117 +++- IdleSpectator/Events/Feeds/InterestFeeds.cs | 79 ++- IdleSpectator/Events/LiveEnsemble.cs | 30 + IdleSpectator/Events/LiveSceneStickyState.cs | 4 + IdleSpectator/HarnessScenarios.cs | 20 + IdleSpectator/IdleHitchProbe.cs | 9 + IdleSpectator/InterestCandidate.cs | 14 + .../InterestDirector.StickyMaintain.cs | 14 +- .../InterestDirector.SwitchPolicy.cs | 164 ++++++ IdleSpectator/InterestDirector.cs | 44 +- IdleSpectator/InterestRegistry.cs | 1 + .../Narrative/NarrativeExposureLedger.cs | 83 ++- .../Narrative/NarrativePersistence.cs | 294 ++++++++++ .../Narrative/NarrativeStoryStore.cs | 512 +++++++++++++++++- .../Narrative/NarrativeThreadRules.cs | 21 +- IdleSpectator/Narrative/StoryScheduler.cs | 49 +- IdleSpectator/Story/LifeSagaRoster.cs | 151 ++++-- IdleSpectator/UnitDossier.cs | 73 ++- IdleSpectator/WatchCaption.cs | 87 ++- IdleSpectator/WorldActivityScanner.cs | 6 +- scripts/harness-run.sh | 7 + 23 files changed, 1716 insertions(+), 118 deletions(-) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index ab98542..113c8fa 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2954,10 +2954,22 @@ public static class AgentHarness bool episodeOk = StoryScheduler.HarnessProbeEpisodeContinuity(out string episode); bool proseOk = EventReason.HarnessProbePresentationProse(out string prose); + bool lifecycleOk = + NarrativeStoryStore.HarnessProbeCombatLifecycle(out string lifecycle); + bool schedulerBoundsOk = + NarrativeStoryStore.HarnessProbeSchedulerBounds(out string schedulerBounds); + bool exposureOk = + NarrativeExposureLedger.HarnessProbeBalance(out string exposure); + bool interruptOk = + InterestDirector.HarnessProbePersonalCombatInterrupt(out string interrupt); + bool graphOk = + NarrativePersistence.HarnessProbeGraphCompaction(out string graph); bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence); bool ok = contextOk && spineOk && combatOk && noveltyOk && participantOk && loverOk && identityOk && episodeOk - && proseOk && persistenceOk; + && proseOk && lifecycleOk && schedulerBoundsOk + && exposureOk && interruptOk + && graphOk && persistenceOk; if (ok) _cmdOk++; else _cmdFail++; Emit( cmd, @@ -2966,7 +2978,10 @@ public static class AgentHarness + "} combat={" + combat + "} novelty={" + novelty + "} participant={" + participant + "} lover={" + lover + "} identity={" + identity + "} episode={" + episode - + "} prose={" + prose + + "} prose={" + prose + "} lifecycle={" + lifecycle + + "} schedulerBounds={" + schedulerBounds + + "} exposure={" + exposure + "} interrupt={" + interrupt + + "} graph={" + graph + "} persistence={" + persistence + "}"); break; } @@ -4408,6 +4423,7 @@ public static class AgentHarness case "hitch_probe": { + _preserveSagaAfterBatch = true; bool on = ParseBool(cmd.value, defaultValue: true); IdleHitchProbe.SetEnabled(on); IdleHitchProbe.ResetStats(); @@ -4416,6 +4432,25 @@ public static class AgentHarness break; } + case "narrative_scheduler_health_probe": + { + _preserveSagaAfterBatch = true; + bool bounded = + StoryScheduler.LastCandidateCount <= NarrativeStoryStore.SchedulerCopyLimit; + bool responsive = StoryScheduler.LastTickMs < 50f; + bool ok = bounded && responsive; + if (ok) _cmdOk++; else _cmdFail++; + Emit( + cmd, + ok, + detail: "schedulerMs=" + StoryScheduler.LastTickMs.ToString("0.00") + + " candidates=" + StoryScheduler.LastCandidateCount + + "/" + NarrativeStoryStore.SchedulerCopyLimit + + " graphThreads=" + NarrativeStoryStore.ThreadCount + + " developments=" + NarrativeStoryStore.DevelopmentCount); + break; + } + case "load_save_slot": { int slot = Mathf.Clamp(ParseInt(cmd.value, 2), 1, 5); diff --git a/IdleSpectator/Events/EventFeedUtil.cs b/IdleSpectator/Events/EventFeedUtil.cs index 1b1bc00..d677f95 100644 --- a/IdleSpectator/Events/EventFeedUtil.cs +++ b/IdleSpectator/Events/EventFeedUtil.cs @@ -119,8 +119,11 @@ public static class EventFeedUtil return null; } + EventPresentability.MarkApproved(candidate); InterestScoring.ScoreCheap(candidate); - return InterestRegistry.Upsert(candidate); + InterestCandidate registered = InterestRegistry.Upsert(candidate); + EventPresentability.MarkApproved(registered); + return registered; } /// @@ -183,8 +186,17 @@ public static class EventFeedUtil return null; } + if (!IsHarnessSource(candidate.Source)) + { + EventPresentability.MarkApproved(candidate); + } InterestScoring.ScoreCheap(candidate); - return InterestRegistry.Upsert(candidate); + InterestCandidate registered = InterestRegistry.Upsert(candidate); + if (!IsHarnessSource(candidate.Source)) + { + EventPresentability.MarkApproved(registered); + } + return registered; } private static bool IsHarnessSource(string source) => diff --git a/IdleSpectator/Events/EventPresentability.cs b/IdleSpectator/Events/EventPresentability.cs index 853774f..301b8e2 100644 --- a/IdleSpectator/Events/EventPresentability.cs +++ b/IdleSpectator/Events/EventPresentability.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + namespace IdleSpectator; /// @@ -6,6 +10,35 @@ namespace IdleSpectator; /// public static class EventPresentability { + private const int CacheLimit = 256; + + private sealed class CacheEntry + { + public string Label = ""; + public long SubjectId; + public long RelatedId; + public InterestLeadKind LeadKind; + public bool Result; + public float LastAccessAt; + } + + private sealed class CacheTicket + { + public string Key = ""; + public CacheEntry Entry; + } + + private static readonly Dictionary Cache = + new Dictionary(CacheLimit, StringComparer.Ordinal); + private static readonly Queue CacheOrder = + new Queue(CacheLimit + 1); + + public static void ClearCache() + { + Cache.Clear(); + CacheOrder.Clear(); + } + /// /// True when the candidate may own Layer A camera. /// Empty Label is only allowed for CharacterLed fill (task chip, no orange reason). @@ -17,12 +50,94 @@ public static class EventPresentability return false; } + if (IsApproved(scene)) + { + return true; + } + if (string.IsNullOrEmpty(scene.Label)) { return scene.LeadKind == InterestLeadKind.CharacterLed; } - return !string.IsNullOrEmpty(UnitDossier.EvaluateSceneLabel(scene, recordDrops: false)); + string key = scene.Key ?? ""; + if (!string.IsNullOrEmpty(key) + && Cache.TryGetValue(key, out CacheEntry cached) + && cached.SubjectId == scene.SubjectId + && cached.RelatedId == scene.RelatedId + && cached.LeadKind == scene.LeadKind + && string.Equals(cached.Label, scene.Label, StringComparison.Ordinal)) + { + cached.LastAccessAt = Time.unscaledTime; + return cached.Result; + } + + bool result = + !string.IsNullOrEmpty(UnitDossier.EvaluateSceneLabel(scene, recordDrops: false)); + if (!string.IsNullOrEmpty(key)) + { + var entry = new CacheEntry + { + Label = scene.Label, + SubjectId = scene.SubjectId, + RelatedId = scene.RelatedId, + LeadKind = scene.LeadKind, + Result = result, + LastAccessAt = Time.unscaledTime + }; + Cache[key] = entry; + CacheOrder.Enqueue(new CacheTicket { Key = key, Entry = entry }); + TrimCache(); + } + return result; + } + + /// + /// 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. + /// + public static void MarkApproved(InterestCandidate scene) + { + if (scene == null) + { + return; + } + + scene.ApprovedPresentationLabel = scene.Label ?? ""; + scene.ApprovedPresentationSubjectId = scene.SubjectId; + scene.ApprovedPresentationRelatedId = scene.RelatedId; + scene.ApprovedPresentationLeadKind = scene.LeadKind; + 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); + + private static void TrimCache() + { + // Happiness bursts can publish hundreds of distinct keys. Scanning the whole + // dictionary to find the oldest entry for every insertion made cache maintenance + // quadratic. Tickets make eviction constant-time and safely ignore overwritten keys. + while (Cache.Count > CacheLimit && CacheOrder.Count > 0) + { + CacheTicket ticket = CacheOrder.Dequeue(); + if (ticket != null + && !string.IsNullOrEmpty(ticket.Key) + && Cache.TryGetValue(ticket.Key, out CacheEntry current) + && ReferenceEquals(current, ticket.Entry)) + { + Cache.Remove(ticket.Key); + } + } } /// diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index 46500b4..f92403e 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -15,6 +15,13 @@ public static class InterestFeeds private static float _lastScannerAt = -999f; private const float ScannerInterval = 1.25f; private static int _tickParity; + public static float LastHappinessMs { get; private set; } + public static float LastScannerMs { get; private set; } + public static float LastBattleScanMs { get; private set; } + public static float LastWarFeedMs { get; private set; } + public static float LastPlotFeedMs { get; private set; } + public static float LastFamilyFeedMs { get; private set; } + public static float LastStatusFeedMs { get; private set; } // Civic aggregate boosts keyed by kingdom+effect. private static readonly Dictionary CivicBoostUntil = new Dictionary(32); @@ -26,6 +33,13 @@ public static class InterestFeeds _lastScannerAt = -999f; CivicBoostUntil.Clear(); InterestDropLog.Clear(); + LastHappinessMs = 0f; + LastScannerMs = 0f; + LastBattleScanMs = 0f; + LastWarFeedMs = 0f; + LastPlotFeedMs = 0f; + LastFamilyFeedMs = 0f; + LastStatusFeedMs = 0f; } /// Keep drain cursor coherent when the happiness router sequence resets. @@ -107,12 +121,17 @@ public static class InterestFeeds public static void Tick() { + long started = System.Diagnostics.Stopwatch.GetTimestamp(); DrainHappiness(); + LastHappinessMs = ElapsedMs(started); PruneCivicBoosts(); + LastScannerMs = 0f; // Stagger battle/character scanner off the happiness drain frame. if ((_tickParity++ & 1) == 0) { + started = System.Diagnostics.Stopwatch.GetTimestamp(); TickScanner(); + LastScannerMs = ElapsedMs(started); } } @@ -750,13 +769,16 @@ public static class InterestFeeds return; } - Actor subject = FindUnitById(occ.SubjectId); + // Happiness can arrive in large bursts on old, populous worlds. Resolving each + // occurrence by walking every living unit turns one drain into O(events * units). + // ActorManager already owns an indexed lookup, so keep this path O(events). + Actor subject = EventFeedUtil.FindAliveById(occ.SubjectId); if (subject == null || !subject.isAlive()) { return; } - Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null; + Actor related = occ.RelatedId != 0 ? EventFeedUtil.FindUnitById(occ.RelatedId) : null; NarrativePresentationModel narrativePresentation = NarrativePresentationFactory.Happiness(occ, subject, related); string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory; @@ -1072,6 +1094,11 @@ public static class InterestFeeds private static void TickScanner() { + LastBattleScanMs = 0f; + LastWarFeedMs = 0f; + LastPlotFeedMs = 0f; + LastFamilyFeedMs = 0f; + LastStatusFeedMs = 0f; float now = Time.unscaledTime; if (now - _lastScannerAt < ScannerInterval) { @@ -1090,6 +1117,7 @@ public static class InterestFeeds && InterestDirector.CurrentIsActive && current.ParticipantCount >= 2; + long stageStarted = System.Diagnostics.Stopwatch.GetTimestamp(); InterestEvent battle; if (stickyCombat) { @@ -1111,10 +1139,14 @@ public static class InterestFeeds } else { - // Quiet / non-sticky: full ranking is OK (one fighter pass per ScanInterval). - battle = WorldActivityScanner.FindHottestBattle(); - WorldActivityScanner.RegisterTopCharacterCandidates(max: 4); + // BattleKeeper plus chunk-local ensembles yields the strongest combat scene + // without collecting every fighter in the world. The old extra four combat + // vignettes duplicated that scene and made large peaceful worlds pay a full + // population scan every refresh. + battle = WorldActivityScanner.FindHottestBattleLight( + current != null ? current.Position : Vector3.zero); } + LastBattleScanMs = ElapsedMs(stageStarted); if (battle != null) { @@ -1131,18 +1163,30 @@ public static class InterestFeeds } // Ongoing wars from WarManager (complements WorldLog war start/end). + stageStarted = System.Diagnostics.Stopwatch.GetTimestamp(); WarInterestFeed.Tick(); + LastWarFeedMs = ElapsedMs(stageStarted); // Active plots from PlotManager / World.plots. + stageStarted = System.Diagnostics.Stopwatch.GetTimestamp(); PlotInterestFeed.Tick(); + LastPlotFeedMs = ElapsedMs(stageStarted); // Active family packs (2+ members) from World.families. + stageStarted = System.Diagnostics.Stopwatch.GetTimestamp(); FamilyInterestFeed.Tick(); + LastFamilyFeedMs = ElapsedMs(stageStarted); // Nearby multi-carrier status clusters. + stageStarted = System.Diagnostics.Stopwatch.GetTimestamp(); StatusOutbreakFeed.Tick(); + LastStatusFeedMs = ElapsedMs(stageStarted); } + private static float ElapsedMs(long started) => + (float)((System.Diagnostics.Stopwatch.GetTimestamp() - started) * 1000d + / System.Diagnostics.Stopwatch.Frequency); + private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId) { var pending = new List(32); @@ -1298,31 +1342,6 @@ public static class InterestFeeds return (hash & 0xFFFF).ToString("x"); } - private static Actor FindUnitById(long id) - { - if (id == 0) - { - return null; - } - - foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) - { - try - { - if (actor != null && actor.getID() == id) - { - return actor; - } - } - catch - { - // continue - } - } - - return null; - } - private static long SafeId(Actor actor) { if (actor == null) diff --git a/IdleSpectator/Events/LiveEnsemble.cs b/IdleSpectator/Events/LiveEnsemble.cs index 4893b5c..37b0a05 100644 --- a/IdleSpectator/Events/LiveEnsemble.cs +++ b/IdleSpectator/Events/LiveEnsemble.cs @@ -111,6 +111,36 @@ public sealed class LiveEnsemble return ensemble != null && ensemble.HasFocus; } + /// + /// Build a combat snapshot from the spatial chunk index only. Unlike + /// , an empty/stale tile never falls back to walking every + /// living unit in the world. Use this for recurring probes over BattleKeeper markers. + /// + public static bool TryBuildCombatLocal( + Vector2 pos, + float radius, + out LiveEnsemble ensemble, + ICollection priorParticipantIds = null, + float newcomerBonus = 8f) + { + ensemble = null; + var fighters = new List(16); + float r2 = radius * radius; + if (!TryForEachNearbyFromChunks( + pos, + radius, + r2, + requireCombat: true, + actor => fighters.Add(actor)) + || fighters.Count == 0) + { + return false; + } + + ensemble = FromCombatFighters(fighters, pos, priorParticipantIds, newcomerBonus); + return ensemble != null && ensemble.HasFocus; + } + /// /// Build a kingdom-vs-kingdom war front from a live . /// Counts use kingdom population; focus prefers living kings then any side unit. diff --git a/IdleSpectator/Events/LiveSceneStickyState.cs b/IdleSpectator/Events/LiveSceneStickyState.cs index 2507629..0c49d01 100644 --- a/IdleSpectator/Events/LiveSceneStickyState.cs +++ b/IdleSpectator/Events/LiveSceneStickyState.cs @@ -22,6 +22,8 @@ public sealed class LiveSceneStickyState public string PresentedReason = ""; public string PendingReason = ""; public float ReasonChangeSince = -999f; + /// Combat must stay cold briefly before its headline is cleared. + public float CombatColdSince = -999f; /// /// After a camp hits 0, stay in mop-up until the thin side recovers to /// (stops 0↔1 Mass↔Battle thrash). @@ -67,6 +69,7 @@ public sealed class LiveSceneStickyState PresentedReason = ""; PendingReason = ""; ReasonChangeSince = -999f; + CombatColdSince = -999f; MopUpActive = false; MopUpSince = -999f; SideAKey = ""; @@ -99,6 +102,7 @@ public sealed class LiveSceneStickyState other.PresentedReason = PresentedReason; other.PendingReason = PendingReason; other.ReasonChangeSince = ReasonChangeSince; + other.CombatColdSince = CombatColdSince; other.MopUpActive = MopUpActive; other.MopUpSince = MopUpSince; other.SideAKey = SideAKey; diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 7eb5c4f..33e4fc1 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -193,6 +193,9 @@ internal static class HarnessScenarios case "narrative_story_first_soak": case "story_first_soak": return NarrativeStoryFirstSoak(); + case "narrative_scheduler_health": + case "scheduler_health": + return NarrativeSchedulerHealth(); case "narrative_catalog": case "narrative_catalog_policy": return NarrativeCatalogPolicy(); @@ -3346,6 +3349,23 @@ internal static class HarnessScenarios }; } + private static List NarrativeSchedulerHealth() + { + return new List + { + Step("nsh0", "dismiss_windows"), + Step("nsh1", "wait_world"), + Step("nsh2", "hitch_probe", value: "true"), + Step("nsh3", "spectator", value: "on"), + Step("nsh4", "director_run", wait: 15f), + Step("nsh5", "narrative_scheduler_health_probe"), + Step("nsh6", "hitch_probe", value: "false"), + Step("nsh7", "soak_probe"), + Step("nsh8", "assert", expect: "no_bad"), + Step("nsh99", "snapshot", value: "preserve_saga") + }; + } + private static List NarrativeCatalogPolicy() { return new List diff --git a/IdleSpectator/IdleHitchProbe.cs b/IdleSpectator/IdleHitchProbe.cs index 528baa6..48a30ab 100644 --- a/IdleSpectator/IdleHitchProbe.cs +++ b/IdleSpectator/IdleHitchProbe.cs @@ -162,6 +162,15 @@ public static class IdleHitchProbe + $"dir={_directorMs:0.0}ms disc={_discoveryMs:0.0}ms cap={_captionMs:0.0}ms " + $"other={_otherMs:0.0}ms roster={LifeSagaRoster.Count} pending={InterestRegistry.PendingCount} " + $"scanMs={LifeSagaRoster.LastWorldScanMs:0.0} softMs={LifeSagaRoster.LastSoftRefillMs:0.0} " + + $"schedulerMs={StoryScheduler.LastTickMs:0.0} schedulerCandidates={StoryScheduler.LastCandidateCount} " + + $"schedulerParts={StoryScheduler.LastThreadCopyMs:0.0}/" + + $"{StoryScheduler.LastThreadScoreMs:0.0}/" + + $"{StoryScheduler.LastPendingCopyMs:0.0}/" + + $"{StoryScheduler.LastProposalMs:0.0} " + + $"feedParts={InterestFeeds.LastHappinessMs:0.0}/" + + $"{InterestFeeds.LastScannerMs:0.0}[{InterestFeeds.LastBattleScanMs:0.0}," + + $"{InterestFeeds.LastWarFeedMs:0.0},{InterestFeeds.LastPlotFeedMs:0.0}," + + $"{InterestFeeds.LastFamilyFeedMs:0.0},{InterestFeeds.LastStatusFeedMs:0.0}] " + $"hot={_hotMark}({_hotMarkMs:0.0}ms)"); _hotMark = ""; _hotMarkMs = 0f; diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index 36535e7..5550fd2 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -86,6 +86,15 @@ public sealed class InterestCandidate /// Unscaled time the theater lead was last a live combat participant. public float TheaterLeadLastCombatAt = -999f; public string Label = ""; + /// + /// Intake-time orange-reason approval fingerprint. Registry candidates are immutable + /// with respect to this identity unless another fully validated upsert replaces it. + /// + public string ApprovedPresentationLabel = ""; + public long ApprovedPresentationSubjectId; + public long ApprovedPresentationRelatedId; + public InterestLeadKind ApprovedPresentationLeadKind; + public bool HasApprovedPresentation; public string AssetId = ""; public string SpeciesId = ""; public string Verb = ""; @@ -341,6 +350,11 @@ public sealed class InterestCandidate TheaterLeadId = TheaterLeadId, TheaterLeadLastCombatAt = TheaterLeadLastCombatAt, Label = Label, + ApprovedPresentationLabel = ApprovedPresentationLabel, + ApprovedPresentationSubjectId = ApprovedPresentationSubjectId, + ApprovedPresentationRelatedId = ApprovedPresentationRelatedId, + ApprovedPresentationLeadKind = ApprovedPresentationLeadKind, + HasApprovedPresentation = HasApprovedPresentation, AssetId = AssetId, SpeciesId = SpeciesId, Verb = Verb, diff --git a/IdleSpectator/InterestDirector.StickyMaintain.cs b/IdleSpectator/InterestDirector.StickyMaintain.cs index 635e1ef..570b423 100644 --- a/IdleSpectator/InterestDirector.StickyMaintain.cs +++ b/IdleSpectator/InterestDirector.StickyMaintain.cs @@ -5,6 +5,8 @@ namespace IdleSpectator; public static partial class InterestDirector { + private const float CombatHeadlineColdGraceSeconds = 4.5f; + /// /// Keep camera on the highest-scored combat participant and rewrite tip/counts /// from a live so subject + scale always match focus. @@ -20,9 +22,19 @@ public static partial class InterestDirector bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now); if (!combatHot) { + if (_current.Sticky.CombatColdSince < 0f) + { + _current.Sticky.CombatColdSince = now; + return; + } + if (now - _current.Sticky.CombatColdSince < CombatHeadlineColdGraceSeconds) + { + return; + } ClearStaleCombatLabel(now); return; } + _current.Sticky.CombatColdSince = -999f; float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold ? CombatFocusThrottleLargeSeconds @@ -1876,7 +1888,7 @@ public static partial class InterestDirector _current.ClearCombatSticky(); _current.ClearTheaterLead(); _lastCombatFocusAt = now; - CameraDirector.Watch(_current.ToInterestEvent()); + CameraDirector.ClearWatchReason(); } /// diff --git a/IdleSpectator/InterestDirector.SwitchPolicy.cs b/IdleSpectator/InterestDirector.SwitchPolicy.cs index bc86bcc..8469121 100644 --- a/IdleSpectator/InterestDirector.SwitchPolicy.cs +++ b/IdleSpectator/InterestDirector.SwitchPolicy.cs @@ -5,6 +5,9 @@ namespace IdleSpectator; public static partial class InterestDirector { + private const float RelatedPersonalCombatCutSeconds = 3f; + private const float PersonalCombatCutSeconds = 6f; + private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch) { if (candidate == null) @@ -34,6 +37,15 @@ public static partial class InterestDirector return false; } + if (candidate != _current + && !NarrativeExposureLedger.MayPresent(candidate)) + { + InterestDropLog.Record( + "combat_exposure_budget", + candidate.Label ?? candidate.Key); + return false; + } + if (_current == null) { return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); @@ -89,6 +101,21 @@ public static partial class InterestDirector return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false); } + if (IsPersonalStoryInterrupt(candidate)) + { + float personalDelay = TouchesCurrentPrincipal(candidate) + ? RelatedPersonalCombatCutSeconds + : PersonalCombatCutSeconds; + if (onCurrent >= personalDelay + && IsWorthWatchingNow(candidate, now, selectingDuringGrace: false)) + { + InterestDropLog.Record( + "combat_story_cut", + $"cur={_current.Key} next={candidate.Key} on={onCurrent:0.#}"); + return true; + } + } + if (IsCombatUrgentPeer(_current, candidate)) { InterestDropLog.Record( @@ -358,6 +385,143 @@ public static partial class InterestDirector && next.TotalScore >= current.TotalScore + urgentMargin; } + private static bool IsPersonalStoryInterrupt(InterestCandidate candidate) + { + if (candidate == null + || candidate.Completion == InterestCompletionKind.CombatActive + || candidate.Completion == InterestCompletionKind.WarFront + || InterestScoring.IsFillScore(candidate.TotalScore) + || IsAmbientShot(candidate)) + { + return false; + } + + if (candidate.Completion == InterestCompletionKind.HappinessGrief) + { + return true; + } + + NarrativeDevelopmentKind kind = + candidate.NarrativePresentation?.DevelopmentKind + ?? NarrativeDevelopmentKind.Unknown; + if (kind == NarrativeDevelopmentKind.BondFormed + || kind == NarrativeDevelopmentKind.BondBroken + || kind == NarrativeDevelopmentKind.ChildBorn + || kind == NarrativeDevelopmentKind.Death + || kind == NarrativeDevelopmentKind.Recovery) + { + return true; + } + + string text = ((candidate.AssetId ?? "") + " " + + (candidate.HappinessEffectId ?? "") + " " + + (candidate.Category ?? "") + " " + + (candidate.Verb ?? "")).ToLowerInvariant(); + return ContainsPersonalToken( + text, + "child", + "baby", + "born", + "parent", + "lover", + "love", + "partner", + "family", + "grief", + "mourn", + "kiss", + "marri"); + } + + private static bool TouchesCurrentPrincipal(InterestCandidate candidate) + { + if (_current == null || candidate == null) + { + return false; + } + + long currentId = _current.SubjectId != 0 + ? _current.SubjectId + : EventFeedUtil.SafeId(_current.FollowUnit); + if (currentId == 0) + { + return false; + } + + if (candidate.SubjectId == currentId + || candidate.RelatedId == currentId + || candidate.PairOwnerId == currentId + || candidate.PairPartnerId == currentId) + { + return true; + } + + for (int i = 0; i < candidate.ParticipantIds.Count; i++) + { + if (candidate.ParticipantIds[i] == currentId) + { + return true; + } + } + + return false; + } + + private static bool ContainsPersonalToken(string text, params string[] tokens) + { + if (string.IsNullOrEmpty(text) || tokens == null) + { + return false; + } + + for (int i = 0; i < tokens.Length; i++) + { + if (text.IndexOf(tokens[i], StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + + public static bool HarnessProbePersonalCombatInterrupt(out string detail) + { + var birth = new InterestCandidate + { + AssetId = "add_child", + Category = "Family", + TotalScore = 80f + }; + var love = new InterestCandidate + { + HappinessEffectId = "fell_in_love", + Category = "Relationship", + TotalScore = 75f + }; + var routine = new InterestCandidate + { + HappinessEffectId = "just_slept", + Category = "Activity", + TotalScore = 70f + }; + var building = new InterestCandidate + { + AssetId = "building_complete", + Category = "Building", + TotalScore = 80f + }; + bool birthCuts = IsPersonalStoryInterrupt(birth); + bool loveCuts = IsPersonalStoryInterrupt(love); + bool routineWaits = !IsPersonalStoryInterrupt(routine); + bool buildingWaits = !IsPersonalStoryInterrupt(building); + bool ok = birthCuts && loveCuts && routineWaits && buildingWaits; + detail = "personal=" + birthCuts + "/" + loveCuts + + " waits=" + routineWaits + "/" + buildingWaits + + " pass=" + ok; + return ok; + } + /// /// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers. /// diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 45cc283..3184439 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -23,6 +23,7 @@ public static partial class InterestDirector public const float InputExitGraceSeconds = 2.5f; public const float QuietGraceSeconds = 6f; public const float CameraSettleGraceSeconds = 3f; + public const float SelectionPollSeconds = 0.12f; private static float _minDwell = MinDwellSeconds; private static float _curiosityDwell = CuriosityDwellSeconds; @@ -42,6 +43,7 @@ public static partial class InterestDirector private static float _lastInterestingAt = -999f; private static float _lastAmbientAt = -999f; private static float _lastFeedsAt = -999f; + private static float _nextSelectAt = -999f; private static float _enabledAt = -999f; private static float _inactiveSince = -999f; private static float _lastCombatFocusAt = -999f; @@ -527,6 +529,7 @@ public static partial class InterestDirector _lastInterestingAt = now; _lastAmbientAt = -999f; _lastFeedsAt = -999f; + _nextSelectAt = now; _enabledAt = now; _inactiveSince = -999f; // Harness scenarios inject their own candidates after enable/focus. @@ -579,6 +582,7 @@ public static partial class InterestDirector _inactiveSince = -999f; _lastAmbientAt = -999f; _lastFeedsAt = -999f; + _nextSelectAt = now; // Re-bind camera to the frozen story beat when still watchable. if (_current != null) { @@ -651,10 +655,15 @@ public static partial class InterestDirector float sinceSwitch = now - _lastSwitchAt; IdleHitchProbe.Mark("story"); + IdleHitchProbe.Mark("planner"); StoryPlanner.Tick(now); + IdleHitchProbe.Mark("planner_done"); LifeSagaRoster.Tick(now); + IdleHitchProbe.Mark("roster_done"); LifeSagaSession.EnsureBound(); + IdleHitchProbe.Mark("session_done"); StoryScheduler.Tick(now); + IdleHitchProbe.Mark("scheduler_done"); IdleHitchProbe.Mark("story_done"); if (ReadPauseActive) @@ -676,19 +685,23 @@ public static partial class InterestDirector MaintainFamilyPack(now, force: false); MaintainStatusOutbreak(now, force: false); - IdleHitchProbe.Mark("select"); - InterestCandidate next = SelectNext(now); - IdleHitchProbe.Mark("select_done"); - if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch)) + if (now >= _nextSelectAt) { - if (!InterestScoring.IsFillScore(next.TotalScore)) + _nextSelectAt = now + SelectionPollSeconds; + IdleHitchProbe.Mark("select"); + InterestCandidate next = SelectNext(now); + IdleHitchProbe.Mark("select_done"); + if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch)) { - _lastInterestingAt = now; - } + if (!InterestScoring.IsFillScore(next.TotalScore)) + { + _lastInterestingAt = now; + } - SwitchTo(next, now, resumableInterrupt: false); - onCurrent = 0f; - sinceSwitch = 0f; + SwitchTo(next, now, resumableInterrupt: false); + onCurrent = 0f; + sinceSwitch = 0f; + } } // Pending events own the camera - never Character-fill over them. @@ -1926,6 +1939,13 @@ public static partial class InterestDirector resume.HappinessEffectId ?? resume.AssetId ?? resume.Key ?? "beat"); InterestRegistry.Remove(resume.Key); } + else if (!NarrativeExposureLedger.MayPresent(resume)) + { + InterestDropLog.Record( + "resume_exposure_budget", + resume.Label ?? resume.Key ?? "combat"); + InterestRegistry.Remove(resume.Key); + } else { SwitchTo(resume, now, resumableInterrupt: false); @@ -2732,7 +2752,6 @@ public static partial class InterestDirector private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt) { - StoryScheduler.NoteActualSelection(next); if (next == null) { return; @@ -2752,6 +2771,9 @@ public static partial class InterestDirector return; } + StoryScheduler.NoteActualSelection(next); + NarrativeExposureLedger.NotePresented(next); + if (_current != null && _current.Resumable && next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs index 21ed75a..04f617b 100644 --- a/IdleSpectator/InterestRegistry.cs +++ b/IdleSpectator/InterestRegistry.cs @@ -55,6 +55,7 @@ public static class InterestRegistry { ByKey.Clear(); } + EventPresentability.ClearCache(); } public static InterestCandidate Upsert(InterestCandidate incoming) diff --git a/IdleSpectator/Narrative/NarrativeExposureLedger.cs b/IdleSpectator/Narrative/NarrativeExposureLedger.cs index 63d95a7..bc0c824 100644 --- a/IdleSpectator/Narrative/NarrativeExposureLedger.cs +++ b/IdleSpectator/Narrative/NarrativeExposureLedger.cs @@ -21,15 +21,22 @@ public static class NarrativeExposureLedger new Dictionary(); private static readonly Dictionary Threads = new Dictionary(StringComparer.Ordinal); + private static readonly List PresentedCuts = new List(6); private static int _maxCharacterCuts; private static int _maxThreadCuts; + private static int _combatRun; + private const int PresentedWindow = 6; + private const int CombatWindowBudget = 2; + private const int CombatRunBudget = 2; public static void Clear() { Characters.Clear(); Threads.Clear(); + PresentedCuts.Clear(); _maxCharacterCuts = 0; _maxThreadCuts = 0; + _combatRun = 0; } public static void NoteCut( @@ -82,6 +89,38 @@ public static class NarrativeExposureLedger return Mathf.Min(24f, exposure.SameFamilyRun * 6f); } + /// + /// Two routine combat cuts may establish a conflict; another story family must then + /// receive space. World-critical combat bypasses this global exposure cap. + /// + public static bool MayPresent(InterestCandidate candidate) + { + if (!IsCombat(candidate) || candidate.EventStrength >= 95f) + { + return true; + } + + int combat = 0; + for (int i = 0; i < PresentedCuts.Count; i++) + { + if (PresentedCuts[i]) combat++; + } + + return _combatRun < CombatRunBudget + && combat < CombatWindowBudget; + } + + public static void NotePresented(InterestCandidate candidate) + { + bool combat = IsCombat(candidate); + PresentedCuts.Add(combat); + while (PresentedCuts.Count > PresentedWindow) + { + PresentedCuts.RemoveAt(0); + } + _combatRun = combat ? _combatRun + 1 : 0; + } + public static bool HarnessProbeBalance(out string detail) { Clear(); @@ -93,9 +132,36 @@ public static class NarrativeExposureLedger float prolific = CharacterDebt(1); float repetition = RepetitionPenalty(1, "combat"); float diverse = RepetitionPenalty(1, "lineage"); - bool ok = underCovered > prolific && repetition > 0f && diverse == 0f; + var combat = new InterestCandidate + { + Completion = InterestCompletionKind.CombatActive, + EventStrength = 80f + }; + var criticalCombat = new InterestCandidate + { + Completion = InterestCompletionKind.CombatActive, + EventStrength = 100f + }; + var family = new InterestCandidate + { + AssetId = "add_child", + EventStrength = 70f + }; + NotePresented(combat); + NotePresented(combat); + bool routineBlocked = !MayPresent(combat); + bool criticalAllowed = 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 + && routineReopened; detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0") + " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0") + + " combat=" + routineBlocked + "/" + criticalAllowed + "/" + routineReopened + " pass=" + ok; Clear(); return ok; @@ -127,4 +193,19 @@ public static class NarrativeExposureLedger exposure.SameFamilyRun = 1; } } + + private static bool IsCombat(InterestCandidate candidate) + { + return candidate != null + && (candidate.Completion == InterestCompletionKind.CombatActive + || candidate.Completion == InterestCompletionKind.WarFront + || string.Equals( + candidate.AssetId, + "live_combat", + StringComparison.OrdinalIgnoreCase) + || string.Equals( + candidate.AssetId, + "live_battle", + StringComparison.OrdinalIgnoreCase)); + } } diff --git a/IdleSpectator/Narrative/NarrativePersistence.cs b/IdleSpectator/Narrative/NarrativePersistence.cs index aafcfe5..52a2873 100644 --- a/IdleSpectator/Narrative/NarrativePersistence.cs +++ b/IdleSpectator/Narrative/NarrativePersistence.cs @@ -19,6 +19,7 @@ public static class NarrativePersistence private const int MaxThreads = 500; private const int MaxConsequences = 2400; private const int MaxFacts = 4000; + private const int MaxCharacters = 1600; private static readonly Dictionary Preferences = new Dictionary(); @@ -109,6 +110,8 @@ public static class NarrativePersistence LastPath = path; LastStatus = "saved events=" + state.events.Count + " developments=" + state.developments.Count + + " threads=" + state.threads.Count + + " characters=" + state.characters.Count + " facts=" + state.facts.Count + " bytes=" + json.Length; LogService.LogInfo("[IdleSpectator] Narrative persistence " + LastStatus); @@ -330,6 +333,7 @@ public static class NarrativePersistence Trim(state.threads, MaxThreads, (a, b) => b.updatedAt.CompareTo(a.updatedAt)); Trim(state.consequences, MaxConsequences, (a, b) => b.occurredAt.CompareTo(a.occurredAt)); Trim(state.facts, MaxFacts, (a, b) => b.worldTime.CompareTo(a.worldTime)); + CompactGraph(state, MaxCharacters); state.Pack(); return state; } @@ -473,6 +477,296 @@ public static class NarrativePersistence } } + /// + /// Trim the sidecar as a graph: retained threads, consequences, and character + /// references must point at retained developments. Active casts and player-selected + /// lives are protected from character compaction. + /// + private static void CompactGraph(NarrativeSaveState state, int characterLimit) + { + if (state == null) + { + return; + } + + var eventDevelopmentIds = new HashSet(StringComparer.Ordinal); + for (int i = 0; i < state.events.Count; i++) + { + NarrativeEventRecordDto item = state.events[i]; + if (item == null) continue; + if (!string.IsNullOrEmpty(item.developmentId)) + { + eventDevelopmentIds.Add(item.developmentId); + } + else if (!string.IsNullOrEmpty(item.id) + && item.id.StartsWith("event:", StringComparison.Ordinal)) + { + eventDevelopmentIds.Add(item.id.Substring("event:".Length)); + } + } + + if (eventDevelopmentIds.Count > 0) + { + state.developments.RemoveAll( + item => item == null + || string.IsNullOrEmpty(item.id) + || !eventDevelopmentIds.Contains(item.id)); + } + + var developments = new HashSet(StringComparer.Ordinal); + for (int i = 0; i < state.developments.Count; i++) + { + NarrativeDevelopmentDto item = state.developments[i]; + if (item != null && !string.IsNullOrEmpty(item.id)) + { + developments.Add(item.id); + } + } + + for (int i = state.threads.Count - 1; i >= 0; i--) + { + NarrativeThreadDto thread = state.threads[i]; + if (thread == null) + { + state.threads.RemoveAt(i); + continue; + } + + thread.developmentIds = Filter(thread.developmentIds, developments); + if (!developments.Contains(thread.latestMeaningfulChangeId)) + { + thread.latestMeaningfulChangeId = thread.developmentIds.Length > 0 + ? thread.developmentIds[thread.developmentIds.Length - 1] + : ""; + } + if (!developments.Contains(thread.openedByDevelopmentId)) + { + thread.openedByDevelopmentId = thread.developmentIds.Length > 0 + ? thread.developmentIds[0] + : thread.latestMeaningfulChangeId; + } + if (string.IsNullOrEmpty(thread.latestMeaningfulChangeId)) + { + state.threads.RemoveAt(i); + } + } + + var threads = new HashSet(StringComparer.Ordinal); + for (int i = 0; i < state.threads.Count; i++) + { + NarrativeThreadDto item = state.threads[i]; + if (item != null && !string.IsNullOrEmpty(item.id)) + { + threads.Add(item.id); + } + } + + state.consequences.RemoveAll( + item => item == null + || string.IsNullOrEmpty(item.id) + || !developments.Contains(item.developmentId)); + var consequences = new HashSet(StringComparer.Ordinal); + for (int i = 0; i < state.consequences.Count; i++) + { + CharacterConsequenceDto item = state.consequences[i]; + if (!string.IsNullOrEmpty(item.threadId) && !threads.Contains(item.threadId)) + { + item.threadId = ""; + } + consequences.Add(item.id); + } + + var protectedCharacters = new HashSet(); + for (int i = 0; i < state.preferences.Count; i++) + { + NarrativePreferenceDto preference = state.preferences[i]; + if (preference != null && preference.characterId != 0) + { + protectedCharacters.Add(preference.characterId); + } + } + for (int i = 0; i < state.threads.Count; i++) + { + NarrativeThreadDto thread = state.threads[i]; + if (thread == null + || thread.status > (int)NarrativeThreadStatus.Dormant) + { + continue; + } + if (thread.protagonistId != 0) + { + protectedCharacters.Add(thread.protagonistId); + } + NarrativeCastRoleDto[] cast = thread.cast ?? new NarrativeCastRoleDto[0]; + for (int j = 0; j < cast.Length; j++) + { + if (cast[j] != null && cast[j].characterId != 0) + { + protectedCharacters.Add(cast[j].characterId); + } + } + } + + for (int i = 0; i < state.characters.Count; i++) + { + CharacterStoryStateDto character = state.characters[i]; + if (character == null) continue; + if (LifeSagaRoster.IsMc(character.characterId) + || LifeSagaRoster.IsPrefer(character.characterId)) + { + protectedCharacters.Add(character.characterId); + } + character.openThreadIds = Filter(character.openThreadIds, threads); + character.resolvedThreadIds = Filter(character.resolvedThreadIds, threads); + character.recentDevelopmentIds = Filter( + character.recentDevelopmentIds, + developments); + character.consequenceIds = Filter(character.consequenceIds, consequences); + character.storyPotential = character.storyMomentum + + character.openThreadIds.Length * 2.5f + + Mathf.Min(6f, character.consequenceIds.Length * 0.8f); + } + + state.characters.RemoveAll(item => item == null || item.characterId == 0); + state.characters.Sort((a, b) => + { + bool aProtected = protectedCharacters.Contains(a.characterId); + bool bProtected = protectedCharacters.Contains(b.characterId); + if (aProtected != bProtected) return aProtected ? -1 : 1; + int potential = b.storyPotential.CompareTo(a.storyPotential); + if (potential != 0) return potential; + return b.lastMeaningfulChangeAt.CompareTo(a.lastMeaningfulChangeAt); + }); + int protectedCount = 0; + for (int i = 0; i < state.characters.Count; i++) + { + if (protectedCharacters.Contains(state.characters[i].characterId)) + { + protectedCount++; + } + } + int keep = Math.Max(Math.Max(0, characterLimit), protectedCount); + if (state.characters.Count > keep) + { + state.characters.RemoveRange(keep, state.characters.Count - keep); + } + } + + private static string[] Filter(string[] values, HashSet retained) + { + if (values == null || values.Length == 0 || retained == null) + { + return new string[0]; + } + + var result = new List(values.Length); + for (int i = 0; i < values.Length; i++) + { + string value = values[i]; + if (!string.IsNullOrEmpty(value) + && retained.Contains(value) + && !result.Contains(value)) + { + result.Add(value); + } + } + + return result.ToArray(); + } + + public static bool HarnessProbeGraphCompaction(out string detail) + { + var state = new NarrativeSaveState(); + state.events.Add(new NarrativeEventRecordDto + { + id = "event:dev:kept", + developmentId = "dev:kept" + }); + state.developments.Add(new NarrativeDevelopmentDto + { + id = "dev:kept", + subjectId = 10, + occurredAt = 2f + }); + state.developments.Add(new NarrativeDevelopmentDto + { + id = "dev:orphan", + subjectId = 30, + occurredAt = 3f + }); + state.threads.Add(new NarrativeThreadDto + { + id = "thread:kept", + protagonistId = 10, + cast = new[] + { + new NarrativeCastRoleDto { characterId = 10, role = "protagonist" }, + new NarrativeCastRoleDto { characterId = 20, role = "partner" } + }, + developmentIds = new[] { "dev:kept", "dev:orphan" }, + latestMeaningfulChangeId = "dev:kept", + status = (int)NarrativeThreadStatus.Active + }); + state.threads.Add(new NarrativeThreadDto + { + id = "thread:orphan", + protagonistId = 30, + developmentIds = new[] { "dev:orphan" }, + latestMeaningfulChangeId = "dev:orphan", + status = (int)NarrativeThreadStatus.Active + }); + state.consequences.Add(new CharacterConsequenceDto + { + id = "consequence:kept", + characterId = 10, + threadId = "thread:kept", + developmentId = "dev:kept" + }); + state.consequences.Add(new CharacterConsequenceDto + { + id = "consequence:orphan", + characterId = 30, + threadId = "thread:orphan", + developmentId = "dev:orphan" + }); + state.characters.Add(new CharacterStoryStateDto + { + characterId = 10, + openThreadIds = new[] { "thread:kept", "thread:orphan" }, + recentDevelopmentIds = new[] { "dev:kept", "dev:orphan" }, + consequenceIds = new[] { "consequence:kept", "consequence:orphan" }, + storyMomentum = 2f, + storyPotential = 99f + }); + state.characters.Add(new CharacterStoryStateDto { characterId = 20 }); + state.characters.Add(new CharacterStoryStateDto + { + characterId = 30, + storyPotential = 200f + }); + + CompactGraph(state, 2); + CharacterStoryStateDto protagonist = + state.characters.Find(item => item.characterId == 10); + bool ok = state.developments.Count == 1 + && state.threads.Count == 1 + && state.consequences.Count == 1 + && state.characters.Count == 2 + && state.characters.Exists(item => item.characterId == 20) + && !state.characters.Exists(item => item.characterId == 30) + && protagonist != null + && protagonist.openThreadIds.Length == 1 + && protagonist.recentDevelopmentIds.Length == 1 + && protagonist.consequenceIds.Length == 1 + && protagonist.storyPotential < 20f; + detail = "graph=" + state.developments.Count + "/" + + state.threads.Count + "/" + state.consequences.Count + + " characters=" + state.characters.Count + + " potential=" + (protagonist?.storyPotential.ToString("0.0") ?? "none") + + " pass=" + ok; + return ok; + } + private static void Trim(List items, int max, Comparison comparison) { items.Sort(comparison); diff --git a/IdleSpectator/Narrative/NarrativeStoryStore.cs b/IdleSpectator/Narrative/NarrativeStoryStore.cs index b655378..1d04e1d 100644 --- a/IdleSpectator/Narrative/NarrativeStoryStore.cs +++ b/IdleSpectator/Narrative/NarrativeStoryStore.cs @@ -9,6 +9,10 @@ public static class NarrativeStoryStore { private const int RecentPerCharacter = 16; private const int ConsequencesPerCharacter = 24; + private const int OpenThreadsPerCharacter = 16; + private const int ResolvedThreadsPerCharacter = 24; + internal const int SchedulerCandidateLimit = 96; + internal const int SchedulerCopyLimit = 128; private static readonly Dictionary Developments = new Dictionary(StringComparer.Ordinal); private static readonly Dictionary Characters = @@ -18,6 +22,17 @@ public static class NarrativeStoryStore private static readonly Dictionary Consequences = new Dictionary(StringComparer.Ordinal); private static readonly List ThreadScratch = new List(32); + private static readonly Dictionary SchedulerCandidates = + new Dictionary(SchedulerCandidateLimit, StringComparer.Ordinal); + private static readonly HashSet SchedulerCopyIds = + new HashSet(StringComparer.Ordinal); + private static readonly List RosterScratch = + new List(LifeSagaRoster.Cap); + private static readonly Dictionary> OpenConflictsByCharacter = + new Dictionary>(); + private static readonly Dictionary> ConflictCastByThread = + new Dictionary>(StringComparer.Ordinal); + private static readonly List IdScratch = new List(SchedulerCandidateLimit); public static int Revision { get; private set; } public static int DevelopmentCount => Developments.Count; @@ -31,6 +46,12 @@ public static class NarrativeStoryStore Threads.Clear(); Consequences.Clear(); ThreadScratch.Clear(); + SchedulerCandidates.Clear(); + SchedulerCopyIds.Clear(); + RosterScratch.Clear(); + OpenConflictsByCharacter.Clear(); + ConflictCastByThread.Clear(); + IdScratch.Clear(); NarrativeEventStore.Clear(); Revision++; StoryScheduler.Clear(); @@ -105,21 +126,52 @@ public static class NarrativeStoryStore internal static void FinishImport() { - foreach (CharacterStoryState state in Characters.Values) - { - if (state == null) continue; - state.OpenThreadIds.RemoveAll(id => !Threads.ContainsKey(id)); - state.ResolvedThreadIds.RemoveAll(id => !Threads.ContainsKey(id)); - state.RecentDevelopmentIds.RemoveAll(id => !Developments.ContainsKey(id)); - state.ConsequenceIds.RemoveAll(id => !Consequences.ContainsKey(id)); - } - + RepairConsistency(); Revision++; StoryScheduler.Clear(); NarrativeTelemetry.Clear(); NarrativePresentationCoverage.Clear(); } + /// + /// A death is a terminal answer to every live personal conflict involving that actor. + /// This keeps a kill/death from becoming a permanent unresolved-question multiplier. + /// + internal static int ResolveCombatThreadsForDeath( + long deceasedId, + NarrativeDevelopment death) + { + if (deceasedId == 0) + { + return 0; + } + + if (!OpenConflictsByCharacter.TryGetValue(deceasedId, out HashSet indexed) + || indexed.Count == 0) + { + return 0; + } + + IdScratch.Clear(); + foreach (string threadId in indexed) IdScratch.Add(threadId); + int resolved = 0; + for (int i = 0; i < IdScratch.Count; i++) + { + NarrativeThread thread = Thread(IdScratch[i]); + if (!IsOpenConflict(thread) || !thread.HasCast(deceasedId)) continue; + ResolveConflictThread(thread, death, deceasedId); + resolved++; + } + IdScratch.Clear(); + + if (resolved > 0) + { + Revision++; + } + + return resolved; + } + public static bool Record(NarrativeDevelopment development) { if (development == null || development.SubjectId == 0 || string.IsNullOrEmpty(development.Id)) @@ -195,6 +247,7 @@ public static class NarrativeStoryStore CharacterStoryState state = GetOrCreateCharacter(protagonistId, protagonistIdentity); MoveThreadReference(state, thread); RecalculatePotential(state); + TrackThread(thread); Revision++; return thread; } @@ -219,16 +272,39 @@ public static class NarrativeStoryStore { if (into == null) return; into.Clear(); - foreach (NarrativeThread thread in Threads.Values) + SchedulerCopyIds.Clear(); + IdScratch.Clear(); + float now = Time.unscaledTime; + foreach (KeyValuePair pair in SchedulerCandidates) { - if (thread != null - && (thread.IsOpen - || (thread.Status == NarrativeThreadStatus.Resolved - && Time.unscaledTime - thread.UpdatedAt < 20f))) + NarrativeThread thread = pair.Value; + if (!IsSchedulerEligible(thread, now)) { - into.Add(thread); + IdScratch.Add(pair.Key); + continue; + } + + AddSchedulerCopy(into, thread); + } + for (int i = 0; i < IdScratch.Count; i++) SchedulerCandidates.Remove(IdScratch[i]); + IdScratch.Clear(); + + // A bounded recent index handles ordinary challengers. Always fold the current + // main cast back in so a quieter long-running MC thread cannot be displaced by + // a burst of one-shot world events. + LifeSagaRoster.CopySlots(RosterScratch); + for (int i = 0; i < RosterScratch.Count && into.Count < SchedulerCopyLimit; i++) + { + CharacterStoryState state = Character(RosterScratch[i].UnitId); + if (state == null) continue; + for (int j = 0; j < state.OpenThreadIds.Count && into.Count < SchedulerCopyLimit; j++) + { + NarrativeThread thread = Thread(state.OpenThreadIds[j]); + if (IsSchedulerEligible(thread, now)) AddSchedulerCopy(into, thread); } } + RosterScratch.Clear(); + SchedulerCopyIds.Clear(); } /// Copies bounded story-potential challengers without scanning the world. @@ -372,8 +448,22 @@ public static class NarrativeStoryStore { state.OpenThreadIds.Remove(thread.Id); state.ResolvedThreadIds.Remove(thread.Id); - if (thread.IsOpen) state.OpenThreadIds.Insert(0, thread.Id); - else state.ResolvedThreadIds.Insert(0, thread.Id); + if (thread.IsOpen) + { + state.OpenThreadIds.Insert(0, thread.Id); + while (state.OpenThreadIds.Count > OpenThreadsPerCharacter) + { + state.OpenThreadIds.RemoveAt(state.OpenThreadIds.Count - 1); + } + } + else + { + state.ResolvedThreadIds.Insert(0, thread.Id); + while (state.ResolvedThreadIds.Count > ResolvedThreadsPerCharacter) + { + state.ResolvedThreadIds.RemoveAt(state.ResolvedThreadIds.Count - 1); + } + } } private static void RecalculatePotential(CharacterStoryState state) @@ -384,6 +474,394 @@ public static class NarrativeStoryStore state.StoryPotential = state.StoryMomentum + unresolved + consequences; } + /// + /// Rebuild derived character references and hot-path indexes from the retained graph, + /// close legacy terminal threads, and recalculate potential instead of trusting cached DTO values. + /// + internal static int RepairConsistency() + { + var deadIds = new HashSet(); + foreach (NarrativeDevelopment development in Developments.Values) + { + if (development != null + && development.Kind == NarrativeDevelopmentKind.Death + && development.SubjectId != 0) + { + deadIds.Add(development.SubjectId); + } + } + + int repaired = 0; + foreach (NarrativeThread thread in Threads.Values) + { + if (thread != null + && thread.Kind == NarrativeThreadKind.Founding + && thread.IsOpen + && thread.Phase == NarrativePhase.Outcome) + { + thread.Status = NarrativeThreadStatus.Resolved; + thread.Resolution = string.IsNullOrEmpty(thread.Resolution) + ? "home established" + : thread.Resolution; + repaired++; + } + + if (!IsOpenConflict(thread)) + { + continue; + } + + NarrativeDevelopment latest = Development(thread.LatestMeaningfulChangeId); + long deceased = DeadCastMember(thread, deadIds); + if (latest?.Kind != NarrativeDevelopmentKind.Kill && deceased == 0) + { + continue; + } + + ResolveConflictThread(thread, latest, deceased); + repaired++; + } + + foreach (CharacterStoryState state in Characters.Values) + { + if (state == null) continue; + state.OpenThreadIds.Clear(); + state.ResolvedThreadIds.Clear(); + state.RecentDevelopmentIds.RemoveAll(id => !Developments.ContainsKey(id)); + state.ConsequenceIds.RemoveAll(id => !Consequences.ContainsKey(id)); + } + + ThreadScratch.Clear(); + foreach (NarrativeThread thread in Threads.Values) + { + if (thread != null && thread.ProtagonistId != 0) + { + ThreadScratch.Add(thread); + } + } + ThreadScratch.Sort((a, b) => a.UpdatedAt.CompareTo(b.UpdatedAt)); + for (int i = 0; i < ThreadScratch.Count; i++) + { + NarrativeThread thread = ThreadScratch[i]; + CharacterStoryState state = GetOrCreateCharacter( + thread.ProtagonistId, + default); + MoveThreadReference(state, thread); + } + ThreadScratch.Clear(); + + foreach (CharacterStoryState state in Characters.Values) + { + RecalculatePotential(state); + } + + RebuildThreadIndexes(); + return repaired; + } + + public static bool HarnessProbeCombatLifecycle(out string detail) + { + Clear(); + var rival = new NarrativeDevelopment + { + Id = "rival:101:202", + Kind = NarrativeDevelopmentKind.RivalEncounter, + Function = NarrativeFunction.Escalation, + SubjectId = 101, + Subject = new LifeSagaIdentity { Id = 101, Name = "Aro", SpeciesId = "human" }, + OtherId = 202, + Other = new LifeSagaIdentity { Id = 202, Name = "Bex", SpeciesId = "orc" }, + OccurredAt = 1f, + Magnitude = 60f + }; + Record(rival); + NarrativeThread thread = Thread("thread:conflict:conflict:101:202:101"); + bool rivalryOpen = thread != null && thread.IsOpen; + + var kill = new NarrativeDevelopment + { + Id = "kill:101:202", + Kind = NarrativeDevelopmentKind.Kill, + Function = NarrativeFunction.Consequence, + SubjectId = 101, + Subject = rival.Subject, + OtherId = 202, + Other = rival.Other, + OccurredAt = 2f, + Magnitude = 90f + }; + Record(kill); + thread = Thread("thread:conflict:conflict:101:202:101"); + CharacterStoryState killer = Character(101); + bool killResolved = thread != null + && !thread.IsOpen + && thread.Phase == NarrativePhase.Outcome + && killer != null + && killer.OpenThreadIds.Count == 0; + + var stale = new NarrativeThread + { + Id = "thread:conflict:legacy:303:404", + Kind = NarrativeThreadKind.PersonalCombat, + ProtagonistId = 303, + LatestMeaningfulChangeId = kill.Id, + Status = NarrativeThreadStatus.Active, + Phase = NarrativePhase.TurningPoint, + UpdatedAt = 3f + }; + stale.AddCast(303, "protagonist"); + stale.AddCast(404, "opponent"); + ImportThread(stale); + ImportCharacter(new CharacterStoryState + { + CharacterId = 303, + StoryMomentum = 2f, + StoryPotential = 99f + }); + int repaired = RepairConsistency(); + CharacterStoryState migrated = Character(303); + bool staleRepaired = !stale.IsOpen + && migrated != null + && migrated.OpenThreadIds.Count == 0 + && migrated.StoryPotential < 20f; + bool ok = rivalryOpen && killResolved && staleRepaired && repaired == 1; + detail = "rivalOpen=" + rivalryOpen + + " killResolved=" + killResolved + + " repaired=" + repaired + + " migratedPotential=" + (migrated?.StoryPotential.ToString("0.0") ?? "none") + + " pass=" + ok; + Clear(); + return ok; + } + + public static bool HarnessProbeSchedulerBounds(out string detail) + { + Clear(); + const int imported = 600; + for (int i = 0; i < imported; i++) + { + var thread = new NarrativeThread + { + Id = "probe:scheduler:" + i, + Kind = NarrativeThreadKind.Partnership, + ProtagonistId = i + 1, + Status = NarrativeThreadStatus.Active, + Phase = NarrativePhase.Pressure, + UpdatedAt = i, + Momentum = i % 8, + Urgency = i % 5 + }; + thread.AddCast(i + 1, "protagonist"); + ImportThread(thread); + } + RepairConsistency(); + + var candidates = new List(SchedulerCopyLimit); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < 200; i++) CopyOpenThreads(candidates); + stopwatch.Stop(); + bool bounded = candidates.Count <= SchedulerCandidateLimit; + StoryScheduler.Clear(); + StoryScheduler.Tick(Time.unscaledTime + 1f); + float schedulerMs = StoryScheduler.LastTickMs; + int schedulerCandidates = StoryScheduler.LastCandidateCount; + bool schedulerFast = schedulerCandidates <= SchedulerCandidateLimit + && schedulerMs < 50f; + + var home = new NarrativeDevelopment + { + Id = "probe:home:700", + Kind = NarrativeDevelopmentKind.HomeFounded, + Function = NarrativeFunction.Consequence, + SubjectId = 700, + Subject = new LifeSagaIdentity { Id = 700, Name = "Founder", SpeciesId = "human" }, + CityKey = "probe-city", + OccurredAt = Time.unscaledTime, + Magnitude = 80f + }; + Record(home); + NarrativeThread homeThread = Thread("thread:home:probe-city:700"); + bool foundingTerminal = homeThread != null + && !homeThread.IsOpen + && homeThread.Status == NarrativeThreadStatus.Resolved; + bool fast = stopwatch.ElapsedMilliseconds < 50; + bool ok = bounded && schedulerFast && foundingTerminal && fast; + detail = "imported=" + imported + + " candidates=" + candidates.Count + "/" + SchedulerCandidateLimit + + " copies200Ms=" + stopwatch.ElapsedMilliseconds + + " scheduler=" + schedulerMs.ToString("0.00") + "ms/" + + schedulerCandidates + + " foundingTerminal=" + foundingTerminal + + " pass=" + ok; + Clear(); + return ok; + } + + private static void AddSchedulerCopy(List into, NarrativeThread thread) + { + if (thread == null || into.Count >= SchedulerCopyLimit + || !SchedulerCopyIds.Add(thread.Id)) + { + return; + } + + into.Add(thread); + } + + private static bool IsSchedulerEligible(NarrativeThread thread, float now) + { + if (thread == null) return false; + if (thread.IsOpen) return true; + float age = now - thread.UpdatedAt; + return thread.Status == NarrativeThreadStatus.Resolved + && age >= 0f + && age < 20f; + } + + private static void TrackThread(NarrativeThread thread) + { + if (thread == null || string.IsNullOrEmpty(thread.Id)) return; + TrackConflict(thread); + if (!IsSchedulerEligible(thread, Time.unscaledTime)) + { + SchedulerCandidates.Remove(thread.Id); + return; + } + + SchedulerCandidates[thread.Id] = thread; + if (SchedulerCandidates.Count <= SchedulerCandidateLimit) return; + + string weakestId = null; + float weakest = float.MaxValue; + foreach (KeyValuePair pair in SchedulerCandidates) + { + float priority = SchedulerIndexPriority(pair.Value); + if (priority < weakest) + { + weakest = priority; + weakestId = pair.Key; + } + } + if (!string.IsNullOrEmpty(weakestId)) SchedulerCandidates.Remove(weakestId); + } + + private static float SchedulerIndexPriority(NarrativeThread thread) + { + if (thread == null) return float.MinValue; + float open = thread.IsOpen ? 50f : 0f; + float phase = thread.Phase == NarrativePhase.TurningPoint ? 16f + : thread.Phase == NarrativePhase.Outcome ? 12f + : thread.Phase == NarrativePhase.Consequence ? 10f : 0f; + float oneShot = thread.Kind == NarrativeThreadKind.Founding ? -40f : 0f; + return open + phase + oneShot + thread.Urgency + thread.Momentum + + thread.UpdatedAt * 0.01f; + } + + private static void RebuildThreadIndexes() + { + SchedulerCandidates.Clear(); + OpenConflictsByCharacter.Clear(); + ConflictCastByThread.Clear(); + foreach (NarrativeThread thread in Threads.Values) TrackThread(thread); + } + + private static void TrackConflict(NarrativeThread thread) + { + UntrackConflict(thread?.Id); + if (!IsOpenConflict(thread)) return; + + var castIds = new HashSet(); + for (int i = 0; i < thread.Cast.Count; i++) + { + long characterId = thread.Cast[i]?.CharacterId ?? 0; + if (characterId == 0 || !castIds.Add(characterId)) continue; + if (!OpenConflictsByCharacter.TryGetValue(characterId, out HashSet ids)) + { + ids = new HashSet(StringComparer.Ordinal); + OpenConflictsByCharacter[characterId] = ids; + } + ids.Add(thread.Id); + } + ConflictCastByThread[thread.Id] = castIds; + } + + private static void UntrackConflict(string threadId) + { + if (string.IsNullOrEmpty(threadId) + || !ConflictCastByThread.TryGetValue(threadId, out HashSet castIds)) + { + return; + } + + foreach (long characterId in castIds) + { + if (!OpenConflictsByCharacter.TryGetValue(characterId, out HashSet ids)) continue; + ids.Remove(threadId); + if (ids.Count == 0) OpenConflictsByCharacter.Remove(characterId); + } + ConflictCastByThread.Remove(threadId); + } + + private static bool IsOpenConflict(NarrativeThread thread) + { + return thread != null + && thread.IsOpen + && (thread.Kind == NarrativeThreadKind.PersonalCombat + || thread.Kind == NarrativeThreadKind.Rivalry); + } + + private static long DeadCastMember(NarrativeThread thread, HashSet deadIds) + { + if (thread == null || deadIds == null || deadIds.Count == 0) + { + return 0; + } + + for (int i = 0; i < thread.Cast.Count; i++) + { + NarrativeCastRole cast = thread.Cast[i]; + if (cast != null && deadIds.Contains(cast.CharacterId)) + { + return cast.CharacterId; + } + } + + return 0; + } + + private static void ResolveConflictThread( + NarrativeThread thread, + NarrativeDevelopment terminal, + long deceasedId) + { + if (thread == null) + { + return; + } + + thread.Status = NarrativeThreadStatus.Resolved; + thread.Phase = NarrativePhase.Outcome; + thread.Resolution = deceasedId != 0 && deceasedId == thread.ProtagonistId + ? "protagonist died" + : "opponent defeated"; + if (terminal != null) + { + thread.UpdatedAt = Mathf.Max(thread.UpdatedAt, terminal.OccurredAt); + thread.LatestMeaningfulChangeId = terminal.Id; + if (!thread.DevelopmentIds.Contains(terminal.Id)) + { + thread.DevelopmentIds.Add(terminal.Id); + } + } + + if (Characters.TryGetValue(thread.ProtagonistId, out CharacterStoryState state)) + { + MoveThreadReference(state, thread); + RecalculatePotential(state); + } + TrackThread(thread); + } + private static float MomentumFor(NarrativeFunction function) { switch (function) diff --git a/IdleSpectator/Narrative/NarrativeThreadRules.cs b/IdleSpectator/Narrative/NarrativeThreadRules.cs index 7068755..f3512af 100644 --- a/IdleSpectator/Narrative/NarrativeThreadRules.cs +++ b/IdleSpectator/Narrative/NarrativeThreadRules.cs @@ -127,19 +127,25 @@ public static class NarrativeThreadRules "Their life has ended", NarrativePhase.Outcome, NarrativeThreadStatus.Resolved); if (t != null) t.Resolution = "died"; AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.LostLife, "deceased", d.OtherId, 100f); + NarrativeStoryStore.ResolveCombatThreadsForDeath(d.SubjectId, d); } private static void ApplyConflict(NarrativeDevelopment d) { string anchor = PairAnchor("conflict", d.SubjectId, d.OtherId); - NarrativeThreadKind kind = d.Kind == NarrativeDevelopmentKind.RivalEncounter - ? NarrativeThreadKind.Rivalry : NarrativeThreadKind.PersonalCombat; + bool terminal = d.Kind == NarrativeDevelopmentKind.Kill; + NarrativeThreadKind kind = terminal + ? NarrativeThreadKind.PersonalCombat + : NarrativeThreadKind.Rivalry; NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( "thread:conflict:" + anchor + ":" + d.SubjectId, kind, d.SubjectId, anchor, d, - "Will this conflict end their repeated struggle?", NarrativePhase.TurningPoint, - NarrativeThreadStatus.Active); - if (d.Kind == NarrativeDevelopmentKind.Kill) + terminal ? "The fight has reached its outcome" + : "Will this conflict become a repeated struggle?", + terminal ? NarrativePhase.Outcome : NarrativePhase.Pressure, + terminal ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active); + if (terminal) { + if (t != null) t.Resolution = "killed opponent"; AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.TookLife, "killer", d.OtherId, 82f); } } @@ -164,9 +170,10 @@ public static class NarrativeThreadRules "thread:home:" + anchor + ":" + d.SubjectId, lost ? NarrativeThreadKind.HomeLoss : NarrativeThreadKind.Founding, d.SubjectId, anchor, d, - lost ? "Where will they go after losing their home?" : "Will what they founded endure?", + lost ? "Where will they go after losing their home?" : "They established a home", lost ? NarrativePhase.TurningPoint : NarrativePhase.Outcome, - NarrativeThreadStatus.Active); + lost ? NarrativeThreadStatus.Active : NarrativeThreadStatus.Resolved); + if (!lost && t != null) t.Resolution = "home established"; AddConsequence(d, t, d.SubjectId, lost ? CharacterConsequenceKind.LostHome : CharacterConsequenceKind.FoundedHome, lost ? "displaced" : "founder", 0, lost ? 90f : 82f); diff --git a/IdleSpectator/Narrative/StoryScheduler.cs b/IdleSpectator/Narrative/StoryScheduler.cs index 97d47bb..aabb728 100644 --- a/IdleSpectator/Narrative/StoryScheduler.cs +++ b/IdleSpectator/Narrative/StoryScheduler.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics; using UnityEngine; namespace IdleSpectator; @@ -13,6 +14,7 @@ public static class StoryScheduler private const float EpisodeMaxSeconds = 75f; private const float EarlyEpisodeSwitchMargin = 30f; private const float EstablishedEpisodeSwitchMargin = 20f; + private const float SchedulerIntervalSeconds = 0.5f; public const int EpisodeCombatShotBudget = 3; private static readonly List OpenThreads = new List(32); private static readonly List Pending = new List(96); @@ -22,6 +24,12 @@ public static class StoryScheduler public static EpisodePlan ActiveEpisode { get; private set; } public static EpisodeShotProposal CurrentProposal { get; private set; } public static string LastProposal => _lastProposal ?? ""; + public static float LastTickMs { get; private set; } + public static int LastCandidateCount { get; private set; } + public static float LastThreadCopyMs { get; private set; } + public static float LastThreadScoreMs { get; private set; } + public static float LastPendingCopyMs { get; private set; } + public static float LastProposalMs { get; private set; } public static void Clear() { @@ -31,16 +39,47 @@ public static class StoryScheduler Pending.Clear(); _nextTickAt = 0f; _lastProposal = ""; + LastTickMs = 0f; + LastCandidateCount = 0; + LastThreadCopyMs = 0f; + LastThreadScoreMs = 0f; + LastPendingCopyMs = 0f; + LastProposalMs = 0f; NarrativeExposureLedger.Clear(); } public static void Tick(float now) { if (now < _nextTickAt) return; - _nextTickAt = now + 0.5f; + long started = Stopwatch.GetTimestamp(); + _nextTickAt = float.PositiveInfinity; + try + { + TickDue(now); + } + finally + { + LastTickMs = (float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency); + // Schedule from completed work, expressed in the caller's time domain. + // A slow tick therefore yields before running again instead of entering + // a permanent catch-up loop on the following frame. + _nextTickAt = now + SchedulerIntervalSeconds + LastTickMs / 1000f; + } + } + + private static void TickDue(float now) + { + LastThreadCopyMs = 0f; + LastThreadScoreMs = 0f; + LastPendingCopyMs = 0f; + LastProposalMs = 0f; + long stageStarted = Stopwatch.GetTimestamp(); NarrativeStoryStore.CopyOpenThreads(OpenThreads); + LastThreadCopyMs = ElapsedMs(stageStarted); + LastCandidateCount = OpenThreads.Count; NarrativeThread best = null; float bestScore = float.MinValue; + stageStarted = Stopwatch.GetTimestamp(); for (int i = 0; i < OpenThreads.Count; i++) { NarrativeThread thread = OpenThreads[i]; @@ -56,6 +95,7 @@ public static class StoryScheduler bestScore = score; } } + LastThreadScoreMs = ElapsedMs(stageStarted); NarrativeThread activeThread = ActiveEpisode != null ? NarrativeStoryStore.Thread(ActiveEpisode.ThreadId) @@ -99,8 +139,12 @@ public static class StoryScheduler AddPendingForPurpose(ActiveEpisode, PurposeFor(selectedThread)); } + stageStarted = Stopwatch.GetTimestamp(); InterestRegistry.CopyPending(Pending); + LastPendingCopyMs = ElapsedMs(stageStarted); + stageStarted = Stopwatch.GetTimestamp(); CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread); + LastProposalMs = ElapsedMs(stageStarted); if (CurrentProposal != null) { NarrativeTelemetry.NoteProposal(ActiveEpisode, CurrentProposal, now); @@ -117,6 +161,9 @@ public static class StoryScheduler + " reason=" + (CurrentProposal?.Reason ?? "none"); } + private static float ElapsedMs(long started) => + (float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency); + public static void NoteActualSelection(InterestCandidate actual) { if (ActiveEpisode == null || actual == null) return; diff --git a/IdleSpectator/Story/LifeSagaRoster.cs b/IdleSpectator/Story/LifeSagaRoster.cs index e6b3efb..109382b 100644 --- a/IdleSpectator/Story/LifeSagaRoster.cs +++ b/IdleSpectator/Story/LifeSagaRoster.cs @@ -19,6 +19,7 @@ public static class LifeSagaRoster private static readonly List Slots = new List(Cap); private static readonly List Scratch = new List(48); private static readonly List ChallengerScratch = new List(64); + private static readonly List ChallengerPriorityScratch = new List(64); private static readonly List UnitScanSnapshot = new List(512); private static readonly HashSet ScanSeen = new HashSet(); private static readonly HashSet PreviousIds = new HashSet(); @@ -34,6 +35,9 @@ public static class LifeSagaRoster private static bool _dirty = true; private static WorldScanPhase _scanPhase = WorldScanPhase.None; private static int _scanIndex; + private static int _scanLimit; + private static int _scanCommitIndex; + private static List _liveScanSource; private static float _scanStartedAt; private static int _scanPinCount; @@ -45,7 +49,12 @@ public static class LifeSagaRoster } /// Units probed per frame during amortized world admission. - private const int WorldScanUnitsPerFrame = 260; + private const int WorldScanUnitsPerFrame = 12; + + /// Full challenger dossiers built per frame after a world scan. + private const int WorldScanBuildsPerFrame = 2; + + private const int MaxScanAdmits = Cap * 2; /// Cadence for starting a spread world admission scan. private const float WorldScanIntervalSeconds = 3.5f; @@ -74,6 +83,7 @@ public static class LifeSagaRoster Slots.Clear(); Scratch.Clear(); ChallengerScratch.Clear(); + ChallengerPriorityScratch.Clear(); UnitScanSnapshot.Clear(); ScanSeen.Clear(); PreviousIds.Clear(); @@ -86,6 +96,9 @@ public static class LifeSagaRoster _roleCacheAt = -999f; _scanPhase = WorldScanPhase.None; _scanIndex = 0; + _scanLimit = 0; + _scanCommitIndex = 0; + _liveScanSource = null; _dirty = true; } @@ -759,8 +772,10 @@ public static class LifeSagaRoster PreviousIds.Clear(); ScanSeen.Clear(); ChallengerScratch.Clear(); + ChallengerPriorityScratch.Clear(); UnitScanSnapshot.Clear(); _scanPinCount = 0; + _scanCommitIndex = 0; _scanStartedAt = now; for (int i = 0; i < Slots.Count; i++) @@ -795,7 +810,16 @@ public static class LifeSagaRoster try { - if (World.world?.units != null) + // The game already maintains an indexed living-unit list. Keep a reference and + // walk a bounded slice per frame instead of copying the entire population up + // front; that copy was itself a recurring main-thread hitch on mature worlds. + List alive = World.world?.units?.units_only_alive; + if (alive != null && alive.Count > 0) + { + _liveScanSource = alive; + _scanLimit = alive.Count; + } + else if (World.world?.units != null) { foreach (Actor actor in World.world.units) { @@ -804,6 +828,8 @@ public static class LifeSagaRoster UnitScanSnapshot.Add(actor); } } + + _scanLimit = UnitScanSnapshot.Count; } } catch @@ -822,10 +848,15 @@ public static class LifeSagaRoster var sw = System.Diagnostics.Stopwatch.StartNew(); if (_scanPhase == WorldScanPhase.Walking) { - int end = Mathf.Min(_scanIndex + WorldScanUnitsPerFrame, UnitScanSnapshot.Count); + int available = _liveScanSource != null + ? Mathf.Min(_scanLimit, _liveScanSource.Count) + : UnitScanSnapshot.Count; + int end = Mathf.Min(_scanIndex + WorldScanUnitsPerFrame, available); for (int i = _scanIndex; i < end; i++) { - Actor actor = UnitScanSnapshot[i]; + Actor actor = _liveScanSource != null + ? _liveScanSource[i] + : UnitScanSnapshot[i]; if (actor == null || !actor.isAlive()) { continue; @@ -842,12 +873,12 @@ public static class LifeSagaRoster continue; } - ChallengerScratch.Add(actor); + ConsiderWorldScanChallenger(actor); ScanSeen.Add(id); } _scanIndex = end; - if (_scanIndex < UnitScanSnapshot.Count) + if (_scanIndex < available) { sw.Stop(); LastWorldScanMs = Mathf.Max(LastWorldScanMs, (float)sw.Elapsed.TotalMilliseconds); @@ -859,14 +890,10 @@ public static class LifeSagaRoster if (_scanPhase == WorldScanPhase.Commit) { - const int MaxScanAdmits = Cap * 2; - if (ChallengerScratch.Count > MaxScanAdmits) - { - ChallengerScratch.Sort(CompareAdmitPriorityDesc); - ChallengerScratch.RemoveRange(MaxScanAdmits, ChallengerScratch.Count - MaxScanAdmits); - } - - for (int i = 0; i < ChallengerScratch.Count; i++) + int end = Mathf.Min( + _scanCommitIndex + WorldScanBuildsPerFrame, + ChallengerScratch.Count); + for (int i = _scanCommitIndex; i < end; i++) { Actor actor = ChallengerScratch[i]; long id = EventFeedUtil.SafeId(actor); @@ -878,12 +905,27 @@ public static class LifeSagaRoster Scratch.Add(BuildSlot(actor, now)); } + _scanCommitIndex = end; + if (_scanCommitIndex < ChallengerScratch.Count) + { + sw.Stop(); + LastWorldScanMs = Mathf.Max( + LastWorldScanMs, + (float)sw.Elapsed.TotalMilliseconds); + return; + } + FinishRefillFromScratch(now, worldScan: true, sw); ChallengerScratch.Clear(); + ChallengerPriorityScratch.Clear(); UnitScanSnapshot.Clear(); ScanSeen.Clear(); + _liveScanSource = null; + _scanLimit = 0; _scanPhase = WorldScanPhase.None; _scanIndex = 0; + _scanCommitIndex = 0; + _nextScanAt = now + WorldScanIntervalSeconds; return; } @@ -1542,6 +1584,36 @@ public static class LifeSagaRoster return AdmitPriority(b).CompareTo(AdmitPriority(a)); } + /// + /// Maintain the best bounded challenger set while walking the world. This avoids + /// sorting every eligible unit together at scan completion. + /// + private static void ConsiderWorldScanChallenger(Actor actor) + { + float priority = AdmitPriority(actor); + int insert = 0; + while (insert < ChallengerPriorityScratch.Count + && ChallengerPriorityScratch[insert] >= priority) + { + insert++; + } + + if (insert >= MaxScanAdmits + && ChallengerScratch.Count >= MaxScanAdmits) + { + return; + } + + ChallengerScratch.Insert(insert, actor); + ChallengerPriorityScratch.Insert(insert, priority); + if (ChallengerScratch.Count > MaxScanAdmits) + { + int last = ChallengerScratch.Count - 1; + ChallengerScratch.RemoveAt(last); + ChallengerPriorityScratch.RemoveAt(last); + } + } + private static float AdmitPriority(Actor actor) { if (actor == null) @@ -1873,12 +1945,44 @@ public static class LifeSagaRoster public static bool IsSpeciesSingleton(Actor actor) { - if (actor == null) + if (actor?.asset == null) { return false; } - return WorldActivityScanner.IsSingletonSpeciesPublic(actor, SpeciesCountCache); + string speciesId = actor.asset.id ?? ""; + if (string.IsNullOrEmpty(speciesId)) + { + return false; + } + + if (!SpeciesCountCache.TryGetValue(speciesId, out int population)) + { + // Roster admission only needs singleton/not-singleton. The actor asset already + // owns its units, so stop at two instead of rebuilding counts for the whole world. + population = 0; + try + { + if (actor.asset.units != null) + { + foreach (Actor sameSpecies in actor.asset.units) + { + if (sameSpecies != null && sameSpecies.isAlive() && ++population >= 2) + { + break; + } + } + } + } + catch + { + population = 2; + } + + SpeciesCountCache[speciesId] = population; + } + + return population == 1; } private static bool ActorMatchesFounder(object meta, Actor actor) @@ -1953,21 +2057,6 @@ public static class LifeSagaRoster _roleCacheAt = now; PlotAuthorCache.Clear(); SpeciesCountCache.Clear(); - try - { - Dictionary counts = WorldActivityScanner.CountSpeciesPopulations(); - if (counts != null) - { - foreach (KeyValuePair kv in counts) - { - SpeciesCountCache[kv.Key] = kv.Value; - } - } - } - catch - { - // ignore - } try { diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 68c63c5..ae5b200 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -582,8 +582,13 @@ public sealed class UnitDossier return ""; } + // Router happiness occurrences already carry authoritative subject/related context. + // Once their generated sentence is proven to start with that living subject, do not + // rescan the entire population looking for additional names on every occurrence. + bool routerOwned = IsTrustedHappinessReason(beat, scene); + // Ownership: never let the reason name a living stranger (not subject/related). - if (ReasonNamesStranger(beat, scene)) + if (!routerOwned && ReasonNamesStranger(beat, scene)) { // Tiered combat tips name sides / duelists on purpose. if (!IsEnsembleCombatReason(beat)) @@ -611,6 +616,44 @@ public sealed class UnitDossier return beat; } + private static bool IsTrustedHappinessReason(string reason, InterestCandidate scene) + { + if (string.IsNullOrEmpty(reason) || scene == null) + { + return false; + } + + string source = scene.Source ?? ""; + if (!source.Equals("happiness", StringComparison.OrdinalIgnoreCase) + && !source.Equals("happiness_hatch", StringComparison.OrdinalIgnoreCase) + && !source.Equals("happiness_alpha", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + Actor subject = scene.FollowUnit; + if (subject == null || !subject.isAlive()) + { + return false; + } + + long subjectId = EventFeedUtil.SafeId(subject); + if (scene.SubjectId != 0 && subjectId != scene.SubjectId) + { + return false; + } + + string subjectName = EventFeedUtil.SafeName(subject); + if (string.IsNullOrEmpty(subjectName) + || !reason.StartsWith(subjectName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + int end = subjectName.Length; + return end >= reason.Length || !char.IsLetterOrDigit(reason[end]); + } + private static bool LeadsWithForeignName(string reason, InterestCandidate scene) { if (string.IsNullOrEmpty(reason) || scene == null) @@ -902,6 +945,14 @@ public sealed class UnitDossier continue; } + // Unnamed creatures commonly report their species as getName() ("Crab", + // "Wolf", ...). Those are catalog nouns, not proper-name strangers: + // "Crab Bomb" must not be rejected merely because a crab is alive. + if (IsSpeciesDisplayName(unit, name)) + { + continue; + } + if (!string.IsNullOrEmpty(subjectName) && name.Equals(subjectName, System.StringComparison.OrdinalIgnoreCase)) { @@ -928,6 +979,26 @@ public sealed class UnitDossier return false; } + private static bool IsSpeciesDisplayName(Actor unit, string name) + { + if (unit?.asset == null || string.IsNullOrEmpty(name)) + { + return false; + } + + string speciesId = unit.asset.id ?? ""; + string display = ActivityAssetCatalog.SpeciesDisplayLabel(speciesId); + string nametag = ActivityAssetCatalog.SpeciesNametagLabel(speciesId); + return name.Equals(speciesId, System.StringComparison.OrdinalIgnoreCase) + || name.Equals( + speciesId.Replace('_', ' '), + System.StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(display) + && name.Equals(display, System.StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(nametag) + && name.Equals(nametag, System.StringComparison.OrdinalIgnoreCase)); + } + /// True when appears as a whole token in . private static bool ContainsWholeName(string reason, string name) { diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 5bbe842..0530068 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -84,6 +84,7 @@ public static class WatchCaption private static readonly TraitSlot[] _traitSlots = new TraitSlot[UnitDossier.MaxTraitChips]; private static GameObject _traitsRow; private static readonly TraitSlot[] _statusSlots = new TraitSlot[UnitDossier.MaxStatusChips]; + private static readonly UnitDossier StatusProbe = new UnitDossier(); private static GameObject _statusesRow; private static int _lastStatusesFingerprint = int.MinValue; @@ -110,6 +111,12 @@ public static class WatchCaption private static bool _visible; private static bool _layoutDirty = true; private static string _layoutKey = ""; + private static string _lastAppliedCaptionFingerprint = ""; + private static int _lastIdentityMemoryRevision = int.MinValue; + private static float _nextRoutineLiveRefreshAt; + private static float _nextRoutineSpineAt; + private const float RoutineLiveRefreshSeconds = 0.2f; + private const float RoutineSpineRefreshSeconds = 0.15f; private static bool _dossierRowsNeedRestore; private static float _nextPortraitAt; private static long _portraitActorId; @@ -496,6 +503,10 @@ public static class WatchCaption _lastHistoryCount = -1; _lastHistorySubjectId = 0; _lastStatusesFingerprint = int.MinValue; + _lastAppliedCaptionFingerprint = ""; + _lastIdentityMemoryRevision = int.MinValue; + _nextRoutineLiveRefreshAt = 0f; + _nextRoutineSpineAt = 0f; _headlineLocked = false; _historyColW = HistoryColMinW; _statusBanner = ""; @@ -619,6 +630,7 @@ public static class WatchCaption LastCaptionText = dossier.CaptionText ?? ""; EnsureBuilt(); ApplyVisual(actor, dossier); + _nextRoutineLiveRefreshAt = Time.unscaledTime + RoutineLiveRefreshSeconds; SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused)); LogCaptionIfChanged(LastCaptionText); } @@ -747,6 +759,13 @@ public static class WatchCaption // Caption Identity/Beat/Context always track camera focus; hover only adds panel depth. LifeSagaViewController.Tick(); ReconcileDossierToFocus(); + float now = Time.unscaledTime; + if (now < _nextRoutineLiveRefreshAt) + { + return; + } + + _nextRoutineLiveRefreshAt = now + RoutineLiveRefreshSeconds; if (!LifeSagaViewController.IsHoverPreview) { RefreshLivePortrait(); @@ -822,7 +841,7 @@ public static class WatchCaption _layoutDirty = true; // Route through spine/rail refresh so caption compose and hover panel stay consistent. - RefreshStorySpine(); + RefreshStorySpine(force: true); } private static bool HasStatusBanner() @@ -981,7 +1000,7 @@ public static class WatchCaption // Mass tip headcount ticks must not Relayout the whole dossier every second. // RefreshStorySpine recomposes Beat+Context and Relayouts when chrome changes. - RefreshStorySpine(); + RefreshStorySpine(force: true); if (headcountOnly) { return; @@ -998,13 +1017,20 @@ public static class WatchCaption /// Quiet context under the orange beat (story spine or stake). /// Recompose Identity/Beat/Context; show Cast/Legacy panel only while hovering. /// - private static void RefreshStorySpine() + private static void RefreshStorySpine(bool force = false) { if (!_visible) { return; } + float now = Time.unscaledTime; + if (!force && now < _nextRoutineSpineAt) + { + return; + } + + _nextRoutineSpineAt = now + RoutineSpineRefreshSeconds; bool statusBanner = HasStatusBanner(); if (statusBanner) { @@ -1112,6 +1138,15 @@ public static class WatchCaption reasonRelatedId, spineFiltered, statusBannerOwnsBeat); + if (string.Equals( + model.Fingerprint, + _lastAppliedCaptionFingerprint, + StringComparison.Ordinal)) + { + return; + } + + _lastAppliedCaptionFingerprint = model.Fingerprint; if (!_headlineLocked) { @@ -1131,7 +1166,10 @@ public static class WatchCaption shown = CaptionComposer.TruncateIdentity(shown, 48); } - _nameText.text = shown; + if (!string.Equals(_nameText.text, shown, StringComparison.Ordinal)) + { + _nameText.text = shown; + } } } else if (_current != null && !string.IsNullOrEmpty(_current.Headline)) @@ -1482,7 +1520,17 @@ public static class WatchCaption private static void ApplyStorySpine(string spine) { - LastStorySpine = spine ?? ""; + string next = spine ?? ""; + bool show = !string.IsNullOrEmpty(next); + if (string.Equals(LastStorySpine, next, StringComparison.Ordinal) + && (_storySpineText == null + || (_storySpineText.gameObject.activeSelf == show + && string.Equals(_storySpineText.text, next, StringComparison.Ordinal)))) + { + return; + } + + LastStorySpine = next; if (_storySpineText == null) { return; @@ -1490,7 +1538,6 @@ public static class WatchCaption // This row is Caption Context (short-arc spine or durable stake). A status // banner replaces Beat only, so Context remains visible and live. - bool show = !string.IsNullOrEmpty(LastStorySpine); _storySpineText.text = show ? LastStorySpine : ""; _storySpineText.color = StorySpineColor; _storySpineText.gameObject.SetActive(show); @@ -1607,6 +1654,15 @@ public static class WatchCaption string prevHeadline = LastHeadline ?? ""; string prevJob = _current.JobLabel ?? ""; string prevTag = _current.IdentityTag ?? ""; + int memoryRevision = SagaProse.MemoryRevision; + if (string.Equals(job, prevJob, StringComparison.Ordinal) + && string.Equals(tag, prevTag, StringComparison.Ordinal) + && memoryRevision == _lastIdentityMemoryRevision) + { + return; + } + + _lastIdentityMemoryRevision = memoryRevision; ApplyComposedCaption( actor, _current.ReasonLine ?? "", @@ -1648,11 +1704,10 @@ public static class WatchCaption return; } - UnitDossier probe = new UnitDossier(); - UnitDossier.RefreshTopStatuses(probe, actor); - int fp = StatusFingerprint(probe); + UnitDossier.RefreshTopStatuses(StatusProbe, actor); + int fp = StatusFingerprint(StatusProbe); if (fp == _lastStatusesFingerprint - && StatusChipsMatch(_current.TopStatuses, probe.TopStatuses)) + && StatusChipsMatch(_current.TopStatuses, StatusProbe.TopStatuses)) { return; } @@ -2085,12 +2140,22 @@ public static class WatchCaption } bool hasReason = !string.IsNullOrEmpty(reasonRichOrPlain); + string next = hasReason ? reasonRichOrPlain : ""; + long subjectId = CurrentUnitId; + long nextOtherId = otherId != 0 && otherId != subjectId ? otherId : 0; + if (_reasonText.gameObject.activeSelf == hasReason + && string.Equals(_reasonText.text, next, StringComparison.Ordinal) + && _reasonOtherId == nextOtherId) + { + return; + } + _reasonText.supportRichText = true; _reasonText.color = ReasonColor; _reasonText.horizontalOverflow = HorizontalWrapMode.Wrap; _reasonText.verticalOverflow = VerticalWrapMode.Overflow; _reasonText.resizeTextForBestFit = false; - _reasonText.text = hasReason ? reasonRichOrPlain : ""; + _reasonText.text = next; _reasonText.gameObject.SetActive(hasReason); WireReasonClick(hasReason ? otherId : 0); } diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index f01d5ad..d133952 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -411,7 +411,9 @@ public static class WorldActivityScanner private static InterestEvent BuildHottestBattleLight(Vector3 anchor) { InterestEvent best = RankBattleKeeper(allFighters: null); - if (LiveEnsemble.TryBuildCombat(anchor, 14f, out LiveEnsemble local) + if (anchor != Vector3.zero + && !float.IsNaN(anchor.x) + && LiveEnsemble.TryBuildCombat(anchor, 14f, out LiveEnsemble local) && local != null && local.ParticipantCount >= 1) { @@ -449,7 +451,7 @@ public static class WorldActivityScanner LiveEnsemble.CollectCombatFightersNear(allFighters, pos, 14f, nearby); ensemble = LiveEnsemble.FromCombatFighters(nearby, pos); } - else if (!LiveEnsemble.TryBuildCombat(pos, 14f, out ensemble)) + else if (!LiveEnsemble.TryBuildCombatLocal(pos, 14f, out ensemble)) { ensemble = null; } diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh index 8439d62..44f6d5b 100755 --- a/scripts/harness-run.sh +++ b/scripts/harness-run.sh @@ -84,6 +84,13 @@ if [[ -z "$scenario" && -z "$raw_cmd" ]]; then fi worldbox_running() { + # Manually launched games may live outside the caller's PID namespace even though + # the mod inbox remains reachable. Allow diagnostics to bypass process discovery + # explicitly without starting a second Steam instance. + if [[ "${HARNESS_ASSUME_RUNNING:-0}" == "1" ]]; then + return 0 + fi + local pid cmd for pid in $(pgrep -a worldbox 2>/dev/null | awk '{print $1}'); do cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)