feat(happiness): enhance happiness event handling and telemetry tracking

- Introduce bounded happiness probe to improve event diagnostics
- Update happiness drain logic to limit processed occurrences per tick
- Enhance telemetry for happiness processing times and maximum items
- Refactor narrative development to include new home-related events
- Improve narrative thread handling for home establishment and recovery
This commit is contained in:
DazedAnon 2026-07-23 22:45:18 -05:00
parent 2293274d79
commit c9000531fc
13 changed files with 373 additions and 49 deletions

View file

@ -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;
}

View file

@ -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;
}

View file

@ -12,10 +12,14 @@ public static class InterestFeeds
{
private static ulong _happinessCursor;
private static readonly List<HappinessOccurrence> HappinessDrain = new List<HappinessOccurrence>(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;

View file

@ -98,7 +98,10 @@ public static class HappinessEventRouter
/// Drain occurrences with Sequence &gt; <paramref name="sinceSeq"/>.
/// Returns the highest sequence drained (pass back next call). Diagnostics may still use <see cref="Latest"/>.
/// </summary>
public static ulong DrainSince(ulong sinceSeq, List<HappinessOccurrence> destination)
public static ulong DrainSince(
ulong sinceSeq,
List<HappinessOccurrence> 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<HappinessOccurrence>(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;

View file

@ -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;

View file

@ -73,6 +73,10 @@ public static partial class InterestDirector
private const float CombatTheaterLeadOutnumberedBonus = 28f;
private const float CombatTheaterLeadNearRadius = 20f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(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);
}
/// <summary>

View file

@ -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,

View file

@ -96,7 +96,8 @@ public enum CharacterConsequenceKind
JoinedPlot,
PlotResolved,
Transformed,
Recovered
Recovered,
FoundHome
}
/// <summary>Evidence-backed semantic state transition. Contains no final player-facing prose.</summary>

View file

@ -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:

View file

@ -621,14 +621,15 @@ public static class NarrativeStoryStore
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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();

View file

@ -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)

View file

@ -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;

View file

@ -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);
}
/// <summary>
@ -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 = "";
}
/// <summary>
/// Quiet context under the orange beat (story spine or stake).
/// Recompose Identity/Beat/Context; show Cast/Legacy panel only while hovering.