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:
parent
2293274d79
commit
c9000531fc
13 changed files with 373 additions and 49 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -98,7 +98,10 @@ public static class HappinessEventRouter
|
|||
/// Drain occurrences with Sequence > <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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue