diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 57d7065..96cb60e 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2961,6 +2961,8 @@ public static class AgentHarness out string participant); bool loverOk = HappinessEventRouter.HarnessProbeLiveLoverRecoveryPolicy(out string lover); + bool boundedHappinessOk = + HappinessEventRouter.HarnessProbeBoundedDrain(out string boundedHappiness); bool identityOk = UnitDossier.HarnessProbeIdentityJobPresentation(out string identity); bool episodeOk = @@ -2985,7 +2987,8 @@ public static class AgentHarness bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence); bool ok = contextOk && spineOk && combatOk && noveltyOk && familyCoalescingOk && nameBoundaryOk - && participantOk && loverOk && identityOk && episodeOk + && participantOk && loverOk && boundedHappinessOk + && identityOk && episodeOk && proseOk && lifecycleOk && schedulerBoundsOk && runtimeCompactionOk && exactPresentationOk @@ -3001,6 +3004,7 @@ public static class AgentHarness + "} familyCoalescing={" + familyCoalescing + "} nameBoundary={" + nameBoundary + "} participant={" + participant + "} lover={" + lover + + "} boundedHappiness={" + boundedHappiness + "} identity={" + identity + "} episode={" + episode + "} prose={" + prose + "} lifecycle={" + lifecycle + "} schedulerBounds={" + schedulerBounds @@ -15219,7 +15223,9 @@ public static class AgentHarness var loadWorld = AccessTools.Method(smType, "loadWorld", new[] { typeof(string), typeof(bool) }); if (loadWorld != null && !string.IsNullOrEmpty(path)) { - loadWorld.Invoke(instance, new object[] { path, true }); + // The boolean is pLoadWorkshop, not "start immediately". Passing true + // dereferences currentWorkshopMapData and falls back to a random world. + loadWorld.Invoke(instance, new object[] { path, false }); detail = $"loadWorld slot={slot} path={path}"; return true; } diff --git a/IdleSpectator/DossierAvatar.cs b/IdleSpectator/DossierAvatar.cs index 80f1d22..e1632a7 100644 --- a/IdleSpectator/DossierAvatar.cs +++ b/IdleSpectator/DossierAvatar.cs @@ -235,17 +235,10 @@ public static class DossierAvatar if (_usingVanilla && _instance != null && _shownActorId == id && _instance.gameObject.activeSelf && formKey == _shownFormKey) { - // Same unit + same life stage: only refresh the floor tile underfoot. + // The portrait and life stage are unchanged. updateTileSprite() rebuilds vanilla + // avatar chrome/layout and produced 45-77ms caption stalls on developed worlds. + // The terrain tile is decorative; refresh it on focus/form changes via show(). HideFallback(); - try - { - _instance.updateTileSprite(); - } - catch - { - // ignore tile refresh failures - } - return; } diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index af7f981..6bc4d38 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -12,10 +12,14 @@ public static class InterestFeeds { private static ulong _happinessCursor; private static readonly List HappinessDrain = new List(64); + private const int MaxHappinessPerTick = 8; private static float _lastScannerAt = -999f; private const float ScannerInterval = 1.25f; private static int _tickParity; public static float LastHappinessMs { get; private set; } + public static int LastHappinessCount { get; private set; } + public static float LastHappinessMaxItemMs { get; private set; } + public static string LastHappinessMaxEffectId { get; private set; } = ""; public static float LastScannerMs { get; private set; } public static float LastBattleScanMs { get; private set; } public static float LastWarFeedMs { get; private set; } @@ -34,6 +38,9 @@ public static class InterestFeeds CivicBoostUntil.Clear(); InterestDropLog.Clear(); LastHappinessMs = 0f; + LastHappinessCount = 0; + LastHappinessMaxItemMs = 0f; + LastHappinessMaxEffectId = ""; LastScannerMs = 0f; LastBattleScanMs = 0f; LastWarFeedMs = 0f; @@ -722,7 +729,13 @@ public static class InterestFeeds private static void DrainHappiness() { - ulong next = HappinessEventRouter.DrainSince(_happinessCursor, HappinessDrain); + ulong next = HappinessEventRouter.DrainSince( + _happinessCursor, + HappinessDrain, + MaxHappinessPerTick); + LastHappinessCount = HappinessDrain.Count; + LastHappinessMaxItemMs = 0f; + LastHappinessMaxEffectId = ""; if (HappinessDrain.Count == 0) { _happinessCursor = next; @@ -731,7 +744,15 @@ public static class InterestFeeds for (int i = 0; i < HappinessDrain.Count; i++) { - IngestHappiness(HappinessDrain[i]); + HappinessOccurrence occurrence = HappinessDrain[i]; + long started = System.Diagnostics.Stopwatch.GetTimestamp(); + IngestHappiness(occurrence); + float elapsed = ElapsedMs(started); + if (elapsed > LastHappinessMaxItemMs) + { + LastHappinessMaxItemMs = elapsed; + LastHappinessMaxEffectId = occurrence?.EffectId ?? ""; + } } _happinessCursor = next; diff --git a/IdleSpectator/HappinessEventRouter.cs b/IdleSpectator/HappinessEventRouter.cs index ba3f2d0..5738817 100644 --- a/IdleSpectator/HappinessEventRouter.cs +++ b/IdleSpectator/HappinessEventRouter.cs @@ -98,7 +98,10 @@ public static class HappinessEventRouter /// Drain occurrences with Sequence > . /// Returns the highest sequence drained (pass back next call). Diagnostics may still use . /// - public static ulong DrainSince(ulong sinceSeq, List destination) + public static ulong DrainSince( + ulong sinceSeq, + List destination, + int maxCount = int.MaxValue) { if (destination == null) { @@ -106,6 +109,11 @@ public static class HappinessEventRouter } destination.Clear(); + if (maxCount <= 0) + { + return sinceSeq; + } + ulong max = sinceSeq; for (int i = 0; i < Recent.Count; i++) { @@ -120,11 +128,43 @@ public static class HappinessEventRouter { max = occ.Sequence; } + + if (destination.Count >= maxCount) + { + break; + } } return max; } + public static bool HarnessProbeBoundedDrain(out string detail) + { + ClearSession(); + for (int i = 0; i < 20; i++) + { + Remember(new HappinessOccurrence + { + EffectId = "harness_bounded_" + i, + Applied = true + }); + } + + var drain = new List(8); + ulong cursor = DrainSince(0, drain, 8); + int first = drain.Count; + cursor = DrainSince(cursor, drain, 8); + int second = drain.Count; + cursor = DrainSince(cursor, drain, 8); + int third = drain.Count; + bool complete = cursor == CurrentSequence; + bool ok = first == 8 && second == 8 && third == 4 && complete; + detail = "chunks=" + first + "/" + second + "/" + third + + " complete=" + complete + " pass=" + ok; + ClearSession(); + return ok; + } + public static void ResetCountersForSubject(long subjectId) { _counterSubjectId = subjectId; diff --git a/IdleSpectator/IdleHitchProbe.cs b/IdleSpectator/IdleHitchProbe.cs index 6160e2f..66d5746 100644 --- a/IdleSpectator/IdleHitchProbe.cs +++ b/IdleSpectator/IdleHitchProbe.cs @@ -33,6 +33,8 @@ public static class IdleHitchProbe private static float _maxDirectorMs; private static float _maxCaptionMs; private static float _maxDiscoveryMs; + private static float _nextDetailLogAt; + private const float DetailLogIntervalSeconds = 0.5f; public static void SetEnabled(bool on) { @@ -56,6 +58,7 @@ public static class IdleHitchProbe _maxDirectorMs = 0f; _maxCaptionMs = 0f; _maxDiscoveryMs = 0f; + _nextDetailLogAt = 0f; _nextSummaryAt = Time.unscaledTime + 5f; } @@ -144,6 +147,11 @@ public static class IdleHitchProbe } float dt = Time.unscaledDeltaTime; + float now = Time.unscaledTime; + bool recordBreaking = dt > _maxDt + || _directorMs > _maxDirectorMs + || _captionMs > _maxCaptionMs + || _discoveryMs > _maxDiscoveryMs; _maxDt = Mathf.Max(_maxDt, dt); _maxDirectorMs = Mathf.Max(_maxDirectorMs, _directorMs); _maxCaptionMs = Mathf.Max(_maxCaptionMs, _captionMs); @@ -156,9 +164,14 @@ public static class IdleHitchProbe if (frameSpike || sliceSpike) { _spikes++; - string idle = SpectatorMode.Active ? "on" : "off"; - LogService.LogInfo( - $"[IdleSpectator][HITCH] spike idle={idle} dt={dt * 1000f:0.0}ms " + // Continuous Unity logging can itself create GC/I/O hitches. Preserve every + // record-breaking sample and otherwise cap detail to two lines per second. + if (recordBreaking || now >= _nextDetailLogAt) + { + _nextDetailLogAt = now + DetailLogIntervalSeconds; + string idle = SpectatorMode.Active ? "on" : "off"; + LogService.LogInfo( + $"[IdleSpectator][HITCH] spike idle={idle} dt={dt * 1000f:0.0}ms " + $"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} " @@ -171,19 +184,34 @@ public static class IdleHitchProbe + $"{EpisodeShotSelector.LastPresentabilityMs:0.0}/" + $"{EpisodeShotSelector.LastClassificationMs:0.0}/" + $"{EpisodeShotSelector.LastScoringMs:0.0} " + + $"selectParts={InterestDirector.LastSelectCopyMs:0.0}/" + + $"{InterestDirector.LastSelectFilterMs:0.0}/" + + $"{InterestDirector.LastSelectStoryMs:0.0}/" + + $"{InterestDirector.LastSelectVarietyMs:0.0} " + $"selectorPresent={EpisodeShotSelector.LastPresentabilityChecks} " + $"selectorSlow={EpisodeShotSelector.LastSlowCandidateMs:0.0}:" + $"{EpisodeShotSelector.LastSlowCandidateKey} " + + $"captionParts={WatchCaption.LastReconcileMs:0.0}/" + + $"{WatchCaption.LastPortraitMs:0.0}/" + + $"{WatchCaption.LastTaskMs:0.0}/" + + $"{WatchCaption.LastIdentityMs:0.0}/" + + $"{WatchCaption.LastStatusesMs:0.0}/" + + $"{WatchCaption.LastHistoryMs:0.0}/" + + $"{WatchCaption.LastReasonMs:0.0}/" + + $"{WatchCaption.LastSpineMs:0.0} " + $"feedParts={InterestFeeds.LastHappinessMs:0.0}/" + + $"{InterestFeeds.LastHappinessCount}/" + + $"{InterestFeeds.LastHappinessMaxItemMs:0.0}:" + + $"{InterestFeeds.LastHappinessMaxEffectId}/" + $"{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; + + $"hot={_hotMark}({_hotMarkMs:0.0}ms)"); + _hotMark = ""; + _hotMarkMs = 0f; + } } - float now = Time.unscaledTime; if (now >= _nextSummaryAt) { _nextSummaryAt = now + 5f; diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 3184439..f5c1473 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -73,6 +73,10 @@ public static partial class InterestDirector private const float CombatTheaterLeadOutnumberedBonus = 28f; private const float CombatTheaterLeadNearRadius = 20f; private static readonly List PendingScratch = new List(96); + public static float LastSelectCopyMs { get; private set; } + public static float LastSelectFilterMs { get; private set; } + public static float LastSelectStoryMs { get; private set; } + public static float LastSelectVarietyMs { get; private set; } public static string CurrentTierName => CurrentScoreLabel; @@ -2007,12 +2011,20 @@ public static partial class InterestDirector private static InterestCandidate SelectNext(float now) { + bool profile = IdleHitchProbe.Enabled; + LastSelectCopyMs = 0f; + LastSelectFilterMs = 0f; + LastSelectStoryMs = 0f; + LastSelectVarietyMs = 0f; + long stageStarted = profile ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; InterestRegistry.CopyPending(PendingScratch); + if (profile) LastSelectCopyMs = SelectStageMs(stageStarted); if (PendingScratch.Count == 0) { return null; } + stageStarted = profile ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; float onCurrent = now - _currentStartedAt; float sinceSwitch = now - _lastSwitchAt; bool softQuiet = StoryPlanner.SoftFillQuietActive; @@ -2061,13 +2073,16 @@ public static partial class InterestDirector PendingScratch.RemoveAt(i); } } + if (profile) LastSelectFilterMs = SelectStageMs(stageStarted); if (PendingScratch.Count == 0) { return null; } + stageStarted = profile ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; InterestCandidate storyChoice = StoryScheduler.SelectForCamera(PendingScratch, _current, now); + if (profile) LastSelectStoryMs = SelectStageMs(stageStarted); if (storyChoice != null) { return storyChoice; @@ -2080,7 +2095,16 @@ public static partial class InterestDirector // When no episode owns a usable development, select bounded ambient/world context. // EnrichTopK runs inside InterestVariety.Pick once - avoid a second top-K meta walk here. - return InterestVariety.Pick(PendingScratch, _current); + stageStarted = profile ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; + InterestCandidate picked = InterestVariety.Pick(PendingScratch, _current); + if (profile) LastSelectVarietyMs = SelectStageMs(stageStarted); + return picked; + } + + private static float SelectStageMs(long started) + { + return (float)((System.Diagnostics.Stopwatch.GetTimestamp() - started) + * 1000d / System.Diagnostics.Stopwatch.Frequency); } /// diff --git a/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs b/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs index e0f890c..7728dc2 100644 --- a/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs +++ b/IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs @@ -177,8 +177,8 @@ public static class NarrativeDevelopmentRouter string developmentId) { return Record( - gained ? NarrativeDevelopmentKind.HomeFounded : NarrativeDevelopmentKind.HomeLost, - gained ? NarrativeFunction.TurningPoint : NarrativeFunction.Consequence, + gained ? NarrativeDevelopmentKind.HomeGained : NarrativeDevelopmentKind.HomeLost, + gained ? NarrativeFunction.Development : NarrativeFunction.Consequence, subject, null, developmentId, diff --git a/IdleSpectator/Narrative/NarrativeModels.cs b/IdleSpectator/Narrative/NarrativeModels.cs index e7a387b..cd5a71a 100644 --- a/IdleSpectator/Narrative/NarrativeModels.cs +++ b/IdleSpectator/Narrative/NarrativeModels.cs @@ -96,7 +96,8 @@ public enum CharacterConsequenceKind JoinedPlot, PlotResolved, Transformed, - Recovered + Recovered, + FoundHome } /// Evidence-backed semantic state transition. Contains no final player-facing prose. diff --git a/IdleSpectator/Narrative/NarrativeProse.cs b/IdleSpectator/Narrative/NarrativeProse.cs index 177696d..44c24a5 100644 --- a/IdleSpectator/Narrative/NarrativeProse.cs +++ b/IdleSpectator/Narrative/NarrativeProse.cs @@ -95,6 +95,8 @@ public static class NarrativeProse return "Founded a new home"; case CharacterConsequenceKind.LostHome: return "Lost their home"; + case CharacterConsequenceKind.FoundHome: + return "Found a home"; case CharacterConsequenceKind.EnteredWar: return "Entered a war"; case CharacterConsequenceKind.SurvivedWar: diff --git a/IdleSpectator/Narrative/NarrativeStoryStore.cs b/IdleSpectator/Narrative/NarrativeStoryStore.cs index f2a870d..29795be 100644 --- a/IdleSpectator/Narrative/NarrativeStoryStore.cs +++ b/IdleSpectator/Narrative/NarrativeStoryStore.cs @@ -621,14 +621,15 @@ public static class NarrativeStoryStore } /// - /// 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. + /// Ambient evidence remains in the event/development stores for persistence and dedupe. + /// A full character/thread/consequence graph is useful only once the event touches the + /// authored cast; projecting every anonymous death, breakup, friendship, move, and birth + /// created hundreds of disposable objects between minute-level compactions. /// private static bool ShouldProjectDevelopment(NarrativeDevelopment development) { if (development == null - || development.Kind != NarrativeDevelopmentKind.ChildBorn + || !DefersAnonymousGraphProjection(development.Kind) || (!string.IsNullOrEmpty(development.EvidenceSource) && development.EvidenceSource.StartsWith( "harness", StringComparison.OrdinalIgnoreCase))) @@ -663,6 +664,23 @@ public static class NarrativeStoryStore || active.HasCast(development.OtherId)); } + private static bool DefersAnonymousGraphProjection(NarrativeDevelopmentKind kind) + { + switch (kind) + { + case NarrativeDevelopmentKind.BondFormed: + case NarrativeDevelopmentKind.BondBroken: + case NarrativeDevelopmentKind.ChildBorn: + case NarrativeDevelopmentKind.FriendFormed: + case NarrativeDevelopmentKind.Death: + case NarrativeDevelopmentKind.HomeGained: + case NarrativeDevelopmentKind.HomeLost: + return true; + default: + return false; + } + } + /// /// Bounds the live narrative graph while protecting the active episode and the /// Saga cast. Persistence already compacts its sidecar; this prevents long-running @@ -1085,12 +1103,15 @@ public static class NarrativeStoryStore var stopwatch = System.Diagnostics.Stopwatch.StartNew(); for (int i = 0; i < 200; i++) CopyOpenThreads(candidates); stopwatch.Stop(); - bool bounded = candidates.Count <= SchedulerCandidateLimit; + // The recent-thread index is capped at SchedulerCandidateLimit, then active + // main-cast threads may be folded back in up to SchedulerCopyLimit. A populated + // live roster therefore legitimately produces more than 96 candidates. + bool bounded = candidates.Count <= SchedulerCopyLimit; StoryScheduler.Clear(); StoryScheduler.Tick(Time.unscaledTime + 1f); float schedulerMs = StoryScheduler.LastTickMs; int schedulerCandidates = StoryScheduler.LastCandidateCount; - bool schedulerFast = schedulerCandidates <= SchedulerCandidateLimit + bool schedulerFast = schedulerCandidates <= SchedulerCopyLimit && schedulerMs < 50f; var home = new NarrativeDevelopment @@ -1112,7 +1133,7 @@ public static class NarrativeStoryStore bool fast = stopwatch.ElapsedMilliseconds < 50; bool ok = bounded && schedulerFast && foundingTerminal && fast; detail = "imported=" + imported - + " candidates=" + candidates.Count + "/" + SchedulerCandidateLimit + + " candidates=" + candidates.Count + "/" + SchedulerCopyLimit + " copies200Ms=" + stopwatch.ElapsedMilliseconds + " scheduler=" + schedulerMs.ToString("0.00") + "ms/" + schedulerCandidates @@ -1137,7 +1158,28 @@ public static class NarrativeStoryStore OccurredAt = 1f, Magnitude = 76f }); - bool ambientBirthDeferred = ThreadCount == 0 && CharacterCount == 0; + Record(new NarrativeDevelopment + { + Id = "probe:compact:ambient-death", + Kind = NarrativeDevelopmentKind.Death, + Function = NarrativeFunction.Resolution, + SubjectId = 9000003, + OtherId = 9000004, + EvidenceSource = "natural_soak", + OccurredAt = 1.1f, + Magnitude = 100f + }); + Record(new NarrativeDevelopment + { + Id = "probe:compact:ambient-home", + Kind = NarrativeDevelopmentKind.HomeGained, + Function = NarrativeFunction.Development, + SubjectId = 9000005, + EvidenceSource = "natural_soak", + OccurredAt = 1.2f, + Magnitude = 64f + }); + bool ambientGraphDeferred = ThreadCount == 0 && CharacterCount == 0; Record(new NarrativeDevelopment { Id = "probe:compact:authored-child", @@ -1151,6 +1193,21 @@ public static class NarrativeStoryStore Magnitude = 76f }); bool authoredBirthProjected = ThreadCount == 2 && CharacterCount == 2; + Record(new NarrativeDevelopment + { + Id = "probe:compact:authored-home", + Kind = NarrativeDevelopmentKind.HomeGained, + Function = NarrativeFunction.Development, + SubjectId = 9000013, + CorrelationKey = "probe-home", + EvidenceSource = "harness_compaction", + OccurredAt = 2.1f, + Magnitude = 64f + }); + NarrativeThread homeThread = Thread("thread:home:probe-home:9000013"); + bool authoredHomeProjected = homeThread != null + && homeThread.Kind == NarrativeThreadKind.Recovery + && !homeThread.IsOpen; Clear(); const int importedDevelopments = 2300; @@ -1221,7 +1278,7 @@ public static class NarrativeStoryStore && Threads.Count <= MaxRuntimeThreads && Consequences.Count <= MaxRuntimeConsequences && Characters.Count <= MaxRuntimeCharacters; - bool ok = ambientBirthDeferred && authoredBirthProjected + bool ok = ambientGraphDeferred && authoredBirthProjected && authoredHomeProjected && compacted && bounded && graphClosed && newestThreadKept; detail = "events=" + NarrativeEventStore.Count + "/" + MaxRuntimeEvents + " developments=" + Developments.Count + "/" + MaxRuntimeDevelopments @@ -1230,7 +1287,9 @@ public static class NarrativeStoryStore + " characters=" + Characters.Count + "/" + MaxRuntimeCharacters + " newest=" + newestThreadKept + " closed=" + graphClosed - + " birth=" + ambientBirthDeferred + "/" + authoredBirthProjected + + " deferred=" + ambientGraphDeferred + + " authoredBirth=" + authoredBirthProjected + + " authoredHome=" + authoredHomeProjected + " ms=" + LastCompactionMs.ToString("0.0") + " pass=" + ok; Clear(); diff --git a/IdleSpectator/Narrative/NarrativeThreadRules.cs b/IdleSpectator/Narrative/NarrativeThreadRules.cs index f3512af..f0069f5 100644 --- a/IdleSpectator/Narrative/NarrativeThreadRules.cs +++ b/IdleSpectator/Narrative/NarrativeThreadRules.cs @@ -166,17 +166,23 @@ public static class NarrativeThreadRules { string anchor = FirstNonEmpty(d.CityKey, d.KingdomKey, d.CorrelationKey, d.SubjectId.ToString()); bool lost = d.Kind == NarrativeDevelopmentKind.HomeLost; + bool founded = d.Kind == NarrativeDevelopmentKind.HomeFounded; NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread( "thread:home:" + anchor + ":" + d.SubjectId, - lost ? NarrativeThreadKind.HomeLoss : NarrativeThreadKind.Founding, + lost ? NarrativeThreadKind.HomeLoss + : founded ? NarrativeThreadKind.Founding : NarrativeThreadKind.Recovery, d.SubjectId, anchor, d, - lost ? "Where will they go after losing their home?" : "They established a home", + lost ? "Where will they go after losing their home?" + : founded ? "They established a home" : "They found a place to belong", lost ? NarrativePhase.TurningPoint : NarrativePhase.Outcome, lost ? NarrativeThreadStatus.Active : NarrativeThreadStatus.Resolved); - if (!lost && t != null) t.Resolution = "home established"; + if (!lost && t != null) t.Resolution = founded ? "home established" : "found a home"; AddConsequence(d, t, d.SubjectId, - lost ? CharacterConsequenceKind.LostHome : CharacterConsequenceKind.FoundedHome, - lost ? "displaced" : "founder", 0, lost ? 90f : 82f); + lost ? CharacterConsequenceKind.LostHome + : founded ? CharacterConsequenceKind.FoundedHome : CharacterConsequenceKind.FoundHome, + lost ? "displaced" : founded ? "founder" : "resident", + 0, + lost ? 90f : founded ? 82f : 64f); } private static void ApplyWar(NarrativeDevelopment d) diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 1160b2c..ef14aad 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -476,7 +476,13 @@ public sealed class UnitDossier } } - string beat = EvaluateSceneLabel(scene, d, recordDrops: true) ?? ""; + // Registry intake already ran the complete stranger/foreign-name gate and stamped + // this exact label + ownership fingerprint. Repeating both population scans when + // the director first paints an approved scene made every cut hitch in large worlds. + bool approvedPresentation = EventPresentability.IsApproved(scene); + string beat = approvedPresentation + ? (EventLabelBeat(scene.Label, d, recordDrops: false) ?? "") + : (EvaluateSceneLabel(scene, d, recordDrops: true) ?? ""); if (string.IsNullOrEmpty(beat)) { d.ReasonRelatedId = 0; @@ -485,7 +491,11 @@ public sealed class UnitDossier // Dossier subject must own the leading proper name on non-ensemble tips. // Catches Follow/Subject drift where the tip Label still names someone else. - if (!IsEnsembleCombatReason(beat) && ReasonLeadsWithForeignDossierName(beat, actor, d, scene)) + // An exact intake approval already proved that any living names are scene owners; + // UnitIsReasonPrincipal above proves this dossier actor owns the selected scene. + if (!approvedPresentation + && !IsEnsembleCombatReason(beat) + && ReasonLeadsWithForeignDossierName(beat, actor, d, scene)) { InterestDropLog.Record("identity_filter", "dossier_foreign:" + beat); d.ReasonRelatedId = 0; diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 7e3cbb2..ddccee2 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -124,6 +124,28 @@ public static class WatchCaption private static string _statusBanner = ""; private static float _statusBannerUntil; private static string _lastLoggedCaption = ""; + // OwnedEventReason performs stranger-name presentability checks against the living + // population. A developed world makes that expensive, while the active director + // candidate is normally unchanged for many 0.2-second caption refreshes. + private static bool _ownedReasonCacheValid; + private static long _ownedReasonActorId; + private static InterestCandidate _ownedReasonScene; + private static string _ownedReasonSceneKey = ""; + private static string _ownedReasonSceneLabel = ""; + private static long _ownedReasonSubjectId; + private static long _ownedReasonRelatedId; + private static bool _ownedReasonQuietGrace; + private static string _ownedReasonHarnessOverride = ""; + private static long _ownedReasonHarnessRelatedId; + private static string _ownedReasonHarnessRelatedName = ""; + public static float LastReconcileMs { get; private set; } + public static float LastPortraitMs { get; private set; } + public static float LastTaskMs { get; private set; } + public static float LastIdentityMs { get; private set; } + public static float LastStatusesMs { get; private set; } + public static float LastHistoryMs { get; private set; } + public static float LastReasonMs { get; private set; } + public static float LastSpineMs { get; private set; } private sealed class TraitSlot { @@ -480,6 +502,7 @@ public static class WatchCaption public static void Clear() { + InvalidateOwnedReasonCache(); _current = null; _boundActor = null; LastHeadline = ""; @@ -595,6 +618,7 @@ public static class WatchCaption LastCaptionText = LastHeadline; LastHistoryPreview = ""; LastHistoryJoined = ""; + InvalidateOwnedReasonCache(); _current = null; _boundActor = null; EnsureBuilt(); @@ -618,6 +642,7 @@ public static class WatchCaption public static void SetFromActor(Actor actor, string label = null) { + InvalidateOwnedReasonCache(); if (!_pinnedWhilePaused) { ClearStatusBanner(); @@ -646,6 +671,7 @@ public static class WatchCaption return; } + InvalidateOwnedReasonCache(); if (!_pinnedWhilePaused) { ClearStatusBanner(); @@ -680,6 +706,8 @@ public static class WatchCaption public static void Update() { + ResetUpdateTelemetry(); + // History / manual pause: keep dossier pinned even after the banner timer expires. if (_pinnedWhilePaused && ModSettings.ShowDossierCaption) { @@ -758,8 +786,10 @@ public static class WatchCaption // Safety net: any focus change that skipped SetFromActor (or rematched follow) // must not leave the nametag on the previous person for a frame. // Caption Identity/Beat/Context always track camera focus; hover only adds panel depth. + long perfStarted = StartPerfSample(); LifeSagaViewController.Tick(); ReconcileDossierToFocus(); + LastReconcileMs = EndPerfSample(perfStarted); float now = Time.unscaledTime; if (now < _nextRoutineLiveRefreshAt) { @@ -769,19 +799,66 @@ public static class WatchCaption _nextRoutineLiveRefreshAt = now + RoutineLiveRefreshSeconds; if (!LifeSagaViewController.IsHoverPreview) { + perfStarted = StartPerfSample(); RefreshLivePortrait(); + LastPortraitMs = EndPerfSample(perfStarted); + + perfStarted = StartPerfSample(); RefreshLiveTask(); + LastTaskMs = EndPerfSample(perfStarted); } + perfStarted = StartPerfSample(); RefreshLiveIdentity(); + LastIdentityMs = EndPerfSample(perfStarted); if (!LifeSagaViewController.IsHoverPreview) { + perfStarted = StartPerfSample(); RefreshLiveStatuses(); + LastStatusesMs = EndPerfSample(perfStarted); + + perfStarted = StartPerfSample(); RefreshHistoryIfChanged(); + LastHistoryMs = EndPerfSample(perfStarted); } + perfStarted = StartPerfSample(); RefreshOwnedReason(); + LastReasonMs = EndPerfSample(perfStarted); + + perfStarted = StartPerfSample(); RefreshStorySpine(); + LastSpineMs = EndPerfSample(perfStarted); + } + + private static void ResetUpdateTelemetry() + { + LastReconcileMs = 0f; + LastPortraitMs = 0f; + LastTaskMs = 0f; + LastIdentityMs = 0f; + LastStatusesMs = 0f; + LastHistoryMs = 0f; + LastReasonMs = 0f; + LastSpineMs = 0f; + } + + private static long StartPerfSample() + { + return IdleHitchProbe.Enabled + ? System.Diagnostics.Stopwatch.GetTimestamp() + : 0; + } + + private static float EndPerfSample(long started) + { + if (started == 0) + { + return 0f; + } + + long elapsed = System.Diagnostics.Stopwatch.GetTimestamp() - started; + return (float)(elapsed * 1000.0 / System.Diagnostics.Stopwatch.Frequency); } /// @@ -852,6 +929,11 @@ public static class WatchCaption public static void ClearStatusBanner() { + if (!string.IsNullOrEmpty(_statusBanner) || _statusBannerUntil != 0f) + { + InvalidateOwnedReasonCache(); + } + _statusBanner = ""; _statusBannerUntil = 0f; if (_reasonText != null) @@ -952,9 +1034,35 @@ public static class WatchCaption return; } + InterestCandidate scene = InterestDirector.CurrentCandidate; + long actorId = EventFeedUtil.SafeId(actor); + string sceneKey = scene != null ? (scene.Key ?? "") : ""; + string sceneLabel = scene != null ? (scene.Label ?? "") : ""; + long subjectId = scene != null ? scene.SubjectId : 0; + long relatedId = scene != null ? scene.RelatedId : 0; + bool quietGrace = InterestDirector.InQuietGrace; + string harnessOverride = UnitDossier.HarnessReasonOverride ?? ""; + long harnessRelatedId = UnitDossier.HarnessReasonRelatedId; + string harnessRelatedName = UnitDossier.HarnessReasonRelatedName ?? ""; + + if (_ownedReasonCacheValid + && _ownedReasonActorId == actorId + && ReferenceEquals(_ownedReasonScene, scene) + && string.Equals(_ownedReasonSceneKey, sceneKey, StringComparison.Ordinal) + && string.Equals(_ownedReasonSceneLabel, sceneLabel, StringComparison.Ordinal) + && _ownedReasonSubjectId == subjectId + && _ownedReasonRelatedId == relatedId + && _ownedReasonQuietGrace == quietGrace + && string.Equals(_ownedReasonHarnessOverride, harnessOverride, StringComparison.Ordinal) + && _ownedReasonHarnessRelatedId == harnessRelatedId + && string.Equals(_ownedReasonHarnessRelatedName, harnessRelatedName, StringComparison.Ordinal)) + { + return; + } + // Honor harness reason override the same way FromActor/BuildStoryBeat does. string next; - if (!string.IsNullOrEmpty(UnitDossier.HarnessReasonOverride)) + if (!string.IsNullOrEmpty(harnessOverride)) { string subject = _current.Name ?? ""; if (string.IsNullOrEmpty(subject)) @@ -962,12 +1070,12 @@ public static class WatchCaption subject = EventFeedUtil.SafeName(actor); } - string relatedName = UnitDossier.HarnessReasonRelatedName ?? ""; - _current.ReasonRelatedId = UnitDossier.HarnessReasonRelatedId != 0 - ? UnitDossier.HarnessReasonRelatedId + string relatedName = harnessRelatedName; + _current.ReasonRelatedId = harnessRelatedId != 0 + ? harnessRelatedId : ActivityLog.ResolveLivingUnitIdByName(relatedName, _current.UnitId); next = ActivityProse.ColorizePersonNames( - UnitDossier.HarnessReasonOverride.Trim(), + harnessOverride.Trim(), subject, relatedName); } @@ -976,6 +1084,18 @@ public static class WatchCaption next = UnitDossier.OwnedEventReason(actor, _current) ?? ""; } + _ownedReasonCacheValid = true; + _ownedReasonActorId = actorId; + _ownedReasonScene = scene; + _ownedReasonSceneKey = sceneKey; + _ownedReasonSceneLabel = sceneLabel; + _ownedReasonSubjectId = subjectId; + _ownedReasonRelatedId = relatedId; + _ownedReasonQuietGrace = quietGrace; + _ownedReasonHarnessOverride = harnessOverride; + _ownedReasonHarnessRelatedId = harnessRelatedId; + _ownedReasonHarnessRelatedName = harnessRelatedName; + string prev = _current.ReasonLine ?? ""; if (next == prev) { @@ -985,7 +1105,6 @@ public static class WatchCaption // Transient ownership miss mid-handoff: keep the last orange reason while a // labeled event tip is still active (avoids a blank frame during Love Follow swaps). // Do not keep across character-fill / empty-Label vignettes. - InterestCandidate scene = InterestDirector.CurrentCandidate; if (string.IsNullOrEmpty(next) && !string.IsNullOrEmpty(prev) && scene != null @@ -1015,6 +1134,21 @@ public static class WatchCaption LastContextLine); } + private static void InvalidateOwnedReasonCache() + { + _ownedReasonCacheValid = false; + _ownedReasonActorId = 0; + _ownedReasonScene = null; + _ownedReasonSceneKey = ""; + _ownedReasonSceneLabel = ""; + _ownedReasonSubjectId = 0; + _ownedReasonRelatedId = 0; + _ownedReasonQuietGrace = false; + _ownedReasonHarnessOverride = ""; + _ownedReasonHarnessRelatedId = 0; + _ownedReasonHarnessRelatedName = ""; + } + /// /// Quiet context under the orange beat (story spine or stake). /// Recompose Identity/Beat/Context; show Cast/Legacy panel only while hovering.