Compare commits
2 commits
54476fb7a1
...
c9000531fc
| Author | SHA1 | Date | |
|---|---|---|---|
| c9000531fc | |||
| 2293274d79 |
25 changed files with 1784 additions and 139 deletions
|
|
@ -2892,6 +2892,14 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "framing_approval_probe":
|
||||
{
|
||||
bool ok = EventPresentability.HarnessProbeFramingApproval(out string detail);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(cmd, ok, detail: detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "interest_story_clear":
|
||||
StoryPlanner.Clear();
|
||||
_cmdOk++;
|
||||
|
|
@ -2944,11 +2952,17 @@ public static class AgentHarness
|
|||
bool spineOk = WatchCaption.HarnessProbeStorySpineRelevance(out string spine);
|
||||
bool combatOk = StickyScoreboard.HarnessProbeReasonStability(out string combat);
|
||||
bool noveltyOk = InterestVariety.HarnessProbeRoutineReplay(out string novelty);
|
||||
bool familyCoalescingOk =
|
||||
InterestVariety.HarnessProbeFamilyCoalescing(out string familyCoalescing);
|
||||
bool nameBoundaryOk =
|
||||
UnitDossier.HarnessProbeNameBoundaries(out string nameBoundary);
|
||||
bool participantOk =
|
||||
NarrativePresentationFactory.HarnessProbeNamedParticipantFallback(
|
||||
out string participant);
|
||||
bool loverOk =
|
||||
HappinessEventRouter.HarnessProbeLiveLoverRecoveryPolicy(out string lover);
|
||||
bool boundedHappinessOk =
|
||||
HappinessEventRouter.HarnessProbeBoundedDrain(out string boundedHappiness);
|
||||
bool identityOk =
|
||||
UnitDossier.HarnessProbeIdentityJobPresentation(out string identity);
|
||||
bool episodeOk =
|
||||
|
|
@ -2958,6 +2972,12 @@ public static class AgentHarness
|
|||
NarrativeStoryStore.HarnessProbeCombatLifecycle(out string lifecycle);
|
||||
bool schedulerBoundsOk =
|
||||
NarrativeStoryStore.HarnessProbeSchedulerBounds(out string schedulerBounds);
|
||||
bool runtimeCompactionOk =
|
||||
NarrativeStoryStore.HarnessProbeRuntimeCompaction(out string runtimeCompaction);
|
||||
bool exactPresentationOk =
|
||||
CameraDirector.HarnessProbeExactPresentationReplay(out string exactPresentation);
|
||||
bool framingApprovalOk =
|
||||
EventPresentability.HarnessProbeFramingApproval(out string framingApproval);
|
||||
bool exposureOk =
|
||||
NarrativeExposureLedger.HarnessProbeBalance(out string exposure);
|
||||
bool interruptOk =
|
||||
|
|
@ -2966,8 +2986,13 @@ public static class AgentHarness
|
|||
NarrativePersistence.HarnessProbeGraphCompaction(out string graph);
|
||||
bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence);
|
||||
bool ok = contextOk && spineOk && combatOk && noveltyOk
|
||||
&& participantOk && loverOk && identityOk && episodeOk
|
||||
&& familyCoalescingOk && nameBoundaryOk
|
||||
&& participantOk && loverOk && boundedHappinessOk
|
||||
&& identityOk && episodeOk
|
||||
&& proseOk && lifecycleOk && schedulerBoundsOk
|
||||
&& runtimeCompactionOk
|
||||
&& exactPresentationOk
|
||||
&& framingApprovalOk
|
||||
&& exposureOk && interruptOk
|
||||
&& graphOk && persistenceOk;
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
|
|
@ -2976,10 +3001,16 @@ public static class AgentHarness
|
|||
ok,
|
||||
detail: "context={" + context + "} spine={" + spine
|
||||
+ "} combat={" + combat + "} novelty={" + novelty
|
||||
+ "} familyCoalescing={" + familyCoalescing
|
||||
+ "} nameBoundary={" + nameBoundary
|
||||
+ "} participant={" + participant + "} lover={" + lover
|
||||
+ "} boundedHappiness={" + boundedHappiness
|
||||
+ "} identity={" + identity + "} episode={" + episode
|
||||
+ "} prose={" + prose + "} lifecycle={" + lifecycle
|
||||
+ "} schedulerBounds={" + schedulerBounds
|
||||
+ "} runtimeCompaction={" + runtimeCompaction
|
||||
+ "} exactPresentation={" + exactPresentation
|
||||
+ "} framingApproval={" + framingApproval
|
||||
+ "} exposure={" + exposure + "} interrupt={" + interrupt
|
||||
+ "} graph={" + graph
|
||||
+ "} persistence={" + persistence + "}");
|
||||
|
|
@ -4446,8 +4477,13 @@ public static class AgentHarness
|
|||
detail: "schedulerMs=" + StoryScheduler.LastTickMs.ToString("0.00")
|
||||
+ " candidates=" + StoryScheduler.LastCandidateCount
|
||||
+ "/" + NarrativeStoryStore.SchedulerCopyLimit
|
||||
+ " graphEvents=" + NarrativeEventStore.Count
|
||||
+ " graphThreads=" + NarrativeStoryStore.ThreadCount
|
||||
+ " developments=" + NarrativeStoryStore.DevelopmentCount);
|
||||
+ " developments=" + NarrativeStoryStore.DevelopmentCount
|
||||
+ " consequences=" + NarrativeStoryStore.ConsequenceCount
|
||||
+ " characters=" + NarrativeStoryStore.CharacterCount
|
||||
+ " compact=" + NarrativeStoryStore.CompactionCount
|
||||
+ "/" + NarrativeStoryStore.LastCompactionMs.ToString("0.0") + "ms");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -7214,7 +7250,7 @@ public static class AgentHarness
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force a sticky StatusOutbreak session: Outbreak - Status (n).
|
||||
/// Force a sticky StatusOutbreak session: Outbreak - Status.
|
||||
/// value = status id (default cursed). Seeds live cursed carriers so maintain recounts stick.
|
||||
/// </summary>
|
||||
private static void DoInterestOutbreakSession(HarnessCommand cmd)
|
||||
|
|
@ -15187,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NeoModLoader.services;
|
||||
using UnityEngine;
|
||||
|
||||
|
|
@ -9,6 +10,9 @@ public static class CameraDirector
|
|||
public static string LastWatchLabel { get; private set; } = "";
|
||||
public static string LastWatchAssetId { get; private set; } = "";
|
||||
public static string LastFormattedWatchTip { get; private set; } = "";
|
||||
private const float ExactPresentationCooldownSeconds = 90f;
|
||||
private static readonly Dictionary<string, float> PresentedLabels =
|
||||
new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static void FocusUnit(Actor actor, bool updateCaption = true)
|
||||
{
|
||||
|
|
@ -34,6 +38,16 @@ public static class CameraDirector
|
|||
return;
|
||||
}
|
||||
|
||||
string incomingLabel = (interest.Label ?? "").Trim();
|
||||
if (!string.IsNullOrEmpty(incomingLabel)
|
||||
&& !string.Equals(incomingLabel, LastWatchLabel, StringComparison.Ordinal)
|
||||
&& WasPresentedRecently(incomingLabel, Time.unscaledTime))
|
||||
{
|
||||
// Sticky maintain paths may reassert a prior label without going through
|
||||
// candidate selection. Do not turn that refresh into a new camera/caption cut.
|
||||
return;
|
||||
}
|
||||
|
||||
Actor follow = ResolveFollowUnit(interest);
|
||||
if (follow != null && follow.isAlive())
|
||||
{
|
||||
|
|
@ -90,10 +104,59 @@ public static class CameraDirector
|
|||
// Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only.
|
||||
if (tipChanged && !framingOnly)
|
||||
{
|
||||
NotePresentedLabel(label, Time.unscaledTime);
|
||||
LogService.LogInfo($"[IdleSpectator] {tip}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool WasPresentedRecently(string label, float now)
|
||||
{
|
||||
string key = EventReason.ReplayStructureKey(label);
|
||||
return !string.IsNullOrEmpty(key)
|
||||
&& PresentedLabels.TryGetValue(key, out float shownAt)
|
||||
&& now - shownAt >= 0f
|
||||
&& now - shownAt < ExactPresentationCooldownSeconds;
|
||||
}
|
||||
|
||||
private static void NotePresentedLabel(string label, float now)
|
||||
{
|
||||
string key = EventReason.ReplayStructureKey(label);
|
||||
if (string.IsNullOrEmpty(key)) return;
|
||||
PresentedLabels[key] = now;
|
||||
if (PresentedLabels.Count <= 128) return;
|
||||
var expired = new List<string>();
|
||||
foreach (KeyValuePair<string, float> entry in PresentedLabels)
|
||||
{
|
||||
if (now - entry.Value >= ExactPresentationCooldownSeconds * 2f)
|
||||
{
|
||||
expired.Add(entry.Key);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < expired.Count; i++) PresentedLabels.Remove(expired[i]);
|
||||
}
|
||||
|
||||
public static void ClearReplayLedger()
|
||||
{
|
||||
PresentedLabels.Clear();
|
||||
}
|
||||
|
||||
public static bool HarnessProbeExactPresentationReplay(out string detail)
|
||||
{
|
||||
PresentedLabels.Clear();
|
||||
NotePresentedLabel("Duel - Aro vs Bex", 10f);
|
||||
bool sameBlocked = WasPresentedRecently("Duel - Aro vs Bex", 20f);
|
||||
bool reframedBlocked =
|
||||
WasPresentedRecently("Mass - Bex (4) vs Aro (7)", 20f);
|
||||
bool otherOpen = !WasPresentedRecently("Duel - Aro vs Cid", 20f);
|
||||
bool expired = !WasPresentedRecently("Duel - Aro vs Bex", 101f);
|
||||
bool ok = sameBlocked && reframedBlocked && otherOpen && expired;
|
||||
detail = "same=" + sameBlocked + " reframed=" + reframedBlocked
|
||||
+ " other=" + otherOpen
|
||||
+ " expired=" + expired + " pass=" + ok;
|
||||
PresentedLabels.Clear();
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static Actor ResolveFollowUnit(InterestEvent interest)
|
||||
{
|
||||
if (interest == null)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -254,7 +254,13 @@ public static class EventLivePipelinesHarness
|
|||
}
|
||||
|
||||
InterestDropLog.Clear();
|
||||
InterestRegistry.ForceExpireContaining("worldlog:" + id, Time.unscaledTime);
|
||||
string expectedKey = string.Equals(
|
||||
id,
|
||||
"disaster_earthquake",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? "earthquake:active"
|
||||
: "worldlog:" + id;
|
||||
InterestRegistry.ForceExpireContaining(expectedKey, Time.unscaledTime);
|
||||
try
|
||||
{
|
||||
if (!TryPostWorldLog(id, unit))
|
||||
|
|
@ -264,10 +270,14 @@ public static class EventLivePipelinesHarness
|
|||
continue;
|
||||
}
|
||||
|
||||
if (InterestRegistry.CountKeysContaining("worldlog:" + id) > 0)
|
||||
if (InterestRegistry.CountKeysContaining(expectedKey) > 0)
|
||||
{
|
||||
EventLiveCoverageLedger.Claim(
|
||||
"worldLog", id, "id", "WorldLogMessage.add", "busy_bypass=ForceLiveEventFeeds");
|
||||
"worldLog",
|
||||
id,
|
||||
"id",
|
||||
"WorldLogMessage.add",
|
||||
"busy_bypass=ForceLiveEventFeeds;key=" + expectedKey);
|
||||
}
|
||||
else if (DropMentions("createsInterest=false", id))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -94,8 +94,8 @@ public static class EventPresentability
|
|||
|
||||
/// <summary>
|
||||
/// Stamp the exact identity that passed the intake gate. Repeated scheduler/director
|
||||
/// checks can trust this fingerprint without rescanning the world; any later mutation
|
||||
/// naturally invalidates it.
|
||||
/// checks can trust this fingerprint without rescanning the world. Ownership changes
|
||||
/// invalidate it; explicitly recognized live framing updates retain approval.
|
||||
/// </summary>
|
||||
public static void MarkApproved(InterestCandidate scene)
|
||||
{
|
||||
|
|
@ -111,16 +111,71 @@ public static class EventPresentability
|
|||
scene.HasApprovedPresentation = true;
|
||||
}
|
||||
|
||||
public static bool IsApproved(InterestCandidate scene) =>
|
||||
scene != null
|
||||
&& scene.HasApprovedPresentation
|
||||
&& scene.ApprovedPresentationSubjectId == scene.SubjectId
|
||||
&& scene.ApprovedPresentationRelatedId == scene.RelatedId
|
||||
&& scene.ApprovedPresentationLeadKind == scene.LeadKind
|
||||
&& string.Equals(
|
||||
scene.ApprovedPresentationLabel,
|
||||
scene.Label ?? "",
|
||||
StringComparison.Ordinal);
|
||||
public static bool IsApproved(InterestCandidate scene)
|
||||
{
|
||||
if (scene == null
|
||||
|| !scene.HasApprovedPresentation
|
||||
|| scene.ApprovedPresentationSubjectId != scene.SubjectId
|
||||
|| scene.ApprovedPresentationRelatedId != scene.RelatedId
|
||||
|| scene.ApprovedPresentationLeadKind != scene.LeadKind)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string approved = scene.ApprovedPresentationLabel ?? "";
|
||||
string current = scene.Label ?? "";
|
||||
if (string.Equals(approved, current, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sticky scene maintenance may refresh scale/tier without changing ownership.
|
||||
// Re-running UnitDossier's stranger scan across a developed world for every
|
||||
// headcount tick made the scheduler proposal stage hitch badly.
|
||||
bool liveFraming =
|
||||
scene.Completion == InterestCompletionKind.CombatActive
|
||||
|| scene.Completion == InterestCompletionKind.WarFront
|
||||
|| scene.Completion == InterestCompletionKind.StatusOutbreak
|
||||
|| scene.Completion == InterestCompletionKind.FamilyPack;
|
||||
if (!liveFraming)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (EventReason.IsHeadcountOnlyChange(approved, current))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return scene.Completion == InterestCompletionKind.CombatActive
|
||||
&& EventReason.IsCombatFramingOnlyChange(approved, current);
|
||||
}
|
||||
|
||||
public static bool HarnessProbeFramingApproval(out string detail)
|
||||
{
|
||||
var scene = new InterestCandidate
|
||||
{
|
||||
SubjectId = 41,
|
||||
RelatedId = 42,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Completion = InterestCompletionKind.CombatActive,
|
||||
Label = "Battle - Alpha (8) vs Beta (4)"
|
||||
};
|
||||
MarkApproved(scene);
|
||||
scene.Label = "Mass - Beta (3) vs Alpha (12)";
|
||||
bool framingKept = IsApproved(scene);
|
||||
scene.Label = "Mass - Gamma (3) vs Alpha (12)";
|
||||
bool foreignCampRejected = !IsApproved(scene);
|
||||
scene.Label = "Mass - Beta (3) vs Alpha (12)";
|
||||
scene.SubjectId = 99;
|
||||
bool ownerMutationRejected = !IsApproved(scene);
|
||||
bool ok = framingKept && foreignCampRejected && ownerMutationRejected;
|
||||
detail = "framing=" + framingKept
|
||||
+ " foreignCamp=" + foreignCampRejected
|
||||
+ " ownerMutation=" + ownerMutationRejected
|
||||
+ " pass=" + ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static void TrimCache()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -61,6 +61,25 @@ public static class EventReason
|
|||
return SameCombatTheaterCamps(prevKey, nextKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable player-facing replay identity. Combat tiers, side order, and live
|
||||
/// headcounts are framing, not new story developments.
|
||||
/// </summary>
|
||||
public static string ReplayStructureKey(string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(label))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (TryCombatCampKey(label, out string combatKey))
|
||||
{
|
||||
return "combat:" + combatKey;
|
||||
}
|
||||
|
||||
return label.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool SameCombatTheaterCamps(string prevKey, string nextKey)
|
||||
{
|
||||
if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b))
|
||||
|
|
@ -140,7 +159,10 @@ public static class EventReason
|
|||
return false;
|
||||
}
|
||||
|
||||
string rest = StripParenCounts(t.Substring(dash + 3).Trim());
|
||||
string rest = StripParenCounts(t.Substring(dash + 3).Trim())
|
||||
.Replace(" ()", "")
|
||||
.Replace("()", "")
|
||||
.Trim();
|
||||
int vs = rest.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase);
|
||||
if (vs < 0)
|
||||
{
|
||||
|
|
@ -714,19 +736,12 @@ public static class EventReason
|
|||
return an + " changes status";
|
||||
}
|
||||
|
||||
// Prose often already includes a verb phrase ("is burning").
|
||||
if (p.StartsWith("is ", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.StartsWith("are ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return an + " " + p;
|
||||
}
|
||||
|
||||
return an + " · " + p;
|
||||
return SubjectLedClause(an, p);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sticky status outbreak orange reason: <c>Outbreak - Cursed (4)</c>.
|
||||
/// Collective status label + carrier count; never unit proper names.
|
||||
/// Stable sticky status outbreak reason, e.g. <c>Outbreak - Cursed</c>.
|
||||
/// Counts remain in the typed ensemble but do not churn the orange story sentence.
|
||||
/// </summary>
|
||||
public static string StatusOutbreak(LiveEnsemble ensemble)
|
||||
{
|
||||
|
|
@ -741,12 +756,12 @@ public static class EventReason
|
|||
return "";
|
||||
}
|
||||
|
||||
string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)
|
||||
? ensemble.SideA.Key.Substring("status:".Length)
|
||||
: "";
|
||||
string label = ensemble.SideA.Display ?? "";
|
||||
if (string.IsNullOrEmpty(label) || LiveEnsemble.IsStatusOutbreakSideKey(label))
|
||||
{
|
||||
string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)
|
||||
? ensemble.SideA.Key.Substring("status:".Length)
|
||||
: "";
|
||||
label = LiveEnsemble.StatusDisplayName(statusId);
|
||||
}
|
||||
|
||||
|
|
@ -767,9 +782,29 @@ public static class EventReason
|
|||
label = "Afflicted";
|
||||
}
|
||||
|
||||
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
|
||||
int n = Math.Max(0, ensemble.SideA.Count);
|
||||
return "Outbreak - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
|
||||
return StatusClusterLabel(statusId, label, n);
|
||||
}
|
||||
|
||||
internal static string StatusClusterLabel(string statusId, string display, int count)
|
||||
{
|
||||
string id = (statusId ?? "").Trim().ToLowerInvariant();
|
||||
if (id == "burning" || id == "burned" || id == "on_fire")
|
||||
{
|
||||
return "Fire spreads through the crowd";
|
||||
}
|
||||
if (id == "drowning")
|
||||
{
|
||||
return "A crowd is drowning";
|
||||
}
|
||||
|
||||
string label = (display ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(label))
|
||||
{
|
||||
label = "Afflicted";
|
||||
}
|
||||
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
|
||||
return "Outbreak - " + label;
|
||||
}
|
||||
|
||||
public static string Trait(Actor a, string traitId, bool gained)
|
||||
|
|
@ -1086,6 +1121,87 @@ public static class EventReason
|
|||
: "Building completed: " + (string.IsNullOrEmpty(id) ? "structure" : id);
|
||||
}
|
||||
|
||||
/// <summary>Turn killer-POV Chronicle shorthand into a camera-readable subject-led beat.</summary>
|
||||
public static string SubjectLedKill(string subjectName, string chronicleLine)
|
||||
{
|
||||
string subject = (subjectName ?? "").Trim();
|
||||
string line = (chronicleLine ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
{
|
||||
return line;
|
||||
}
|
||||
if (line.StartsWith("Killed ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return subject + " kills " + line.Substring("Killed ".Length);
|
||||
}
|
||||
return string.IsNullOrEmpty(line) ? subject + " takes a life" : subject + " · " + line;
|
||||
}
|
||||
|
||||
/// <summary>Turn friend-POV Chronicle shorthand into a camera-readable subject-led beat.</summary>
|
||||
public static string SubjectLedFriend(string subjectName, string chronicleLine)
|
||||
{
|
||||
string subject = (subjectName ?? "").Trim();
|
||||
string line = (chronicleLine ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
{
|
||||
return line;
|
||||
}
|
||||
if (line.StartsWith("Befriended ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return subject + " befriends " + line.Substring("Befriended ".Length);
|
||||
}
|
||||
return string.IsNullOrEmpty(line)
|
||||
? subject + " forms a friendship"
|
||||
: subject + " · " + line;
|
||||
}
|
||||
|
||||
/// <summary>Turn lover-POV Chronicle shorthand into a complete relationship beat.</summary>
|
||||
public static string SubjectLedLover(string subjectName, string chronicleLine)
|
||||
{
|
||||
const string prefix = "Became lovers with ";
|
||||
string subject = (subjectName ?? "").Trim();
|
||||
string line = (chronicleLine ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
{
|
||||
return line;
|
||||
}
|
||||
if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string partner = line.Substring(prefix.Length).Trim();
|
||||
if (!string.IsNullOrEmpty(partner))
|
||||
{
|
||||
return subject + " and " + partner + " become lovers";
|
||||
}
|
||||
}
|
||||
return SubjectLedClause(subject, line);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turn a POV-style clause into a complete orange story sentence.
|
||||
/// Chronicle and happiness sources sometimes omit the subject or capitalize
|
||||
/// the verb as though it were a standalone history entry.
|
||||
/// </summary>
|
||||
public static string SubjectLedClause(string subjectName, string clause)
|
||||
{
|
||||
string subject = (subjectName ?? "").Trim();
|
||||
string line = (clause ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
{
|
||||
return line;
|
||||
}
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
return subject + " experiences a change";
|
||||
}
|
||||
if (line.StartsWith(subject, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return line;
|
||||
}
|
||||
|
||||
return subject + " " + char.ToLowerInvariant(line[0])
|
||||
+ (line.Length > 1 ? line.Substring(1) : "");
|
||||
}
|
||||
|
||||
private static string NormalizeBuildingDisplay(string buildingId, string display)
|
||||
{
|
||||
string raw = (buildingId ?? "").Trim().ToLowerInvariant();
|
||||
|
|
@ -1094,12 +1210,28 @@ public static class EventReason
|
|||
for (int i = 0; i < cultureSuffixes.Length; i++)
|
||||
{
|
||||
string suffix = "_" + cultureSuffixes[i];
|
||||
if (!raw.EndsWith(suffix, StringComparison.Ordinal))
|
||||
int suffixAt = raw.LastIndexOf(suffix, StringComparison.Ordinal);
|
||||
if (suffixAt < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string baseId = raw.Substring(0, raw.Length - suffix.Length);
|
||||
string variant = raw.Substring(suffixAt + suffix.Length);
|
||||
bool validVariant = string.IsNullOrEmpty(variant);
|
||||
if (!validVariant && variant[0] == '_')
|
||||
{
|
||||
validVariant = variant.Length > 1;
|
||||
for (int j = 1; j < variant.Length && validVariant; j++)
|
||||
{
|
||||
validVariant = char.IsDigit(variant[j]);
|
||||
}
|
||||
}
|
||||
if (!validVariant)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string baseId = raw.Substring(0, suffixAt);
|
||||
string fallback = HumanizeId(raw);
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| value.Equals(fallback, StringComparison.OrdinalIgnoreCase))
|
||||
|
|
@ -1133,12 +1265,35 @@ public static class EventReason
|
|||
string building = BuildingComplete(null, "tent_human", spectacle: false);
|
||||
bool buildingClean = building == "Building completed: Tent"
|
||||
&& building.IndexOf("Human", StringComparison.OrdinalIgnoreCase) < 0;
|
||||
string variantBuilding = BuildingComplete(null, "windmill_elf_0", spectacle: false);
|
||||
bool variantBuildingClean = variantBuilding == "Building completed: Windmill"
|
||||
&& variantBuilding.IndexOf("Elf", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& variantBuilding.IndexOf("0", StringComparison.Ordinal) < 0;
|
||||
string kill = SubjectLedKill("Aro", "Killed Tampoo (monkey)");
|
||||
bool killClean = kill == "Aro kills Tampoo (monkey)";
|
||||
string friend = SubjectLedFriend("Eceas", "Befriended Oreero");
|
||||
bool friendClean = friend == "Eceas befriends Oreero";
|
||||
string lover = SubjectLedLover("Starna", "Became lovers with Skenstyr");
|
||||
bool loverClean = lover == "Starna and Skenstyr become lovers";
|
||||
string clause = SubjectLedClause("Tampoo", "Is starving");
|
||||
bool clauseClean = clause == "Tampoo is starving";
|
||||
string fire = StatusClusterLabel("burning", "Burning", 3);
|
||||
string drowning = StatusClusterLabel("drowning", "Drowning", 8);
|
||||
string outbreak = StatusClusterLabel("cursed", "Cursed", 12);
|
||||
bool fireClean = fire == "Fire spreads through the crowd";
|
||||
bool hazardClean = drowning == "A crowd is drowning"
|
||||
&& outbreak == "Outbreak - Cursed";
|
||||
string species = LiveEnsemble.SpeciesDisplay("slava");
|
||||
string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay("Slava");
|
||||
bool collectiveClean = string.Equals(
|
||||
species, kingdomAsSpecies, StringComparison.OrdinalIgnoreCase);
|
||||
bool ok = buildingClean && collectiveClean;
|
||||
detail = "building='" + building + "' species='" + species
|
||||
bool ok = buildingClean && variantBuildingClean && killClean && friendClean
|
||||
&& loverClean && clauseClean && fireClean && hazardClean && collectiveClean;
|
||||
detail = "building='" + building + "' variant='" + variantBuilding
|
||||
+ "' kill='" + kill + "' friend='" + friend + "' lover='" + lover
|
||||
+ "' clause='" + clause
|
||||
+ "' fire='" + fire + "' drowning='" + drowning
|
||||
+ "' outbreak='" + outbreak + "' species='" + species
|
||||
+ "' kingdomSpecies='" + kingdomAsSpecies + "' pass=" + ok;
|
||||
return ok;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -160,24 +167,40 @@ public static class InterestFeeds
|
|||
|
||||
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
|
||||
string kingdom = message.special1 ?? "";
|
||||
string key = "worldlog:" + assetId + ":" + kingdom;
|
||||
bool earthquake = string.Equals(
|
||||
assetId,
|
||||
"disaster_earthquake",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
// Earthquake.startQuake and its WorldLog are two observations of one event.
|
||||
// Give both the same durable card so the player never sees
|
||||
// "Earthquake shakes the land" immediately followed by bare "Earthquake".
|
||||
string key = earthquake
|
||||
? "earthquake:active"
|
||||
: "worldlog:" + assetId + ":" + kingdom;
|
||||
float boost = PeekCivicBoost(kingdom, assetId);
|
||||
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
|
||||
string label = entry.MakeLabel(message);
|
||||
|
||||
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
|
||||
{
|
||||
if (earthquake)
|
||||
{
|
||||
label = EventReason.Earthquake(follow);
|
||||
}
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"worldlog",
|
||||
earthquake ? "earthquake" : "worldlog",
|
||||
category,
|
||||
label,
|
||||
assetId,
|
||||
earthquake ? "earthquake" : assetId,
|
||||
evtSeed + boost,
|
||||
pos,
|
||||
follow,
|
||||
minWatch: entry.MinWatch,
|
||||
maxWatch: entry.MaxWatch);
|
||||
maxWatch: earthquake ? 45f : entry.MaxWatch,
|
||||
completion: earthquake
|
||||
? InterestCompletionKind.EarthquakeActive
|
||||
: InterestCompletionKind.FixedDwell);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -186,16 +209,19 @@ public static class InterestFeeds
|
|||
{
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"worldlog",
|
||||
earthquake ? "earthquake" : "worldlog",
|
||||
category,
|
||||
label,
|
||||
assetId,
|
||||
earthquake ? "Earthquake shakes the land" : label,
|
||||
earthquake ? "earthquake" : assetId,
|
||||
evtSeed + boost,
|
||||
position,
|
||||
follow: null,
|
||||
locationOnly: true,
|
||||
minWatch: entry.MinWatch,
|
||||
maxWatch: entry.MaxWatch);
|
||||
maxWatch: earthquake ? 45f : entry.MaxWatch,
|
||||
completion: earthquake
|
||||
? InterestCompletionKind.EarthquakeActive
|
||||
: InterestCompletionKind.FixedDwell);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -657,6 +683,19 @@ public static class InterestFeeds
|
|||
|
||||
long sid = SafeId(subject);
|
||||
long rid = SafeId(related);
|
||||
string cameraLabel = label ?? milestoneKey;
|
||||
if (milestoneKey.Equals("milestone_kill", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cameraLabel = EventReason.SubjectLedKill(SafeName(subject), cameraLabel);
|
||||
}
|
||||
else if (milestoneKey.Equals("milestone_friend", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cameraLabel = EventReason.SubjectLedFriend(SafeName(subject), cameraLabel);
|
||||
}
|
||||
else if (milestoneKey.Equals("milestone_lover", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cameraLabel = EventReason.SubjectLedLover(SafeName(subject), cameraLabel);
|
||||
}
|
||||
// Shared key space with happiness canonical merges.
|
||||
string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid;
|
||||
var candidate = new InterestCandidate
|
||||
|
|
@ -672,7 +711,7 @@ public static class InterestFeeds
|
|||
RelatedUnit = related,
|
||||
SubjectId = sid,
|
||||
RelatedId = rid,
|
||||
Label = label ?? milestoneKey,
|
||||
Label = cameraLabel,
|
||||
// Milestone id owns AssetId so kill/lover/friend crumbs cool as a class
|
||||
// (species id used to land every hunter tip on arc:asset:elf).
|
||||
AssetId = milestoneKey,
|
||||
|
|
@ -690,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;
|
||||
|
|
@ -699,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;
|
||||
|
|
@ -831,7 +884,10 @@ public static class InterestFeeds
|
|||
}
|
||||
else if (!string.IsNullOrEmpty(occ.PlainClause))
|
||||
{
|
||||
label = occ.SubjectName + " " + occ.PlainClause;
|
||||
string subjectName = string.IsNullOrWhiteSpace(occ.SubjectName)
|
||||
? EventFeedUtil.SafeName(subject)
|
||||
: occ.SubjectName.Trim();
|
||||
label = EventReason.SubjectLedClause(subjectName, occ.PlainClause);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -62,7 +62,16 @@ public static class StatusOutbreakFeed
|
|||
return;
|
||||
}
|
||||
|
||||
ApplySticky(registered, ensemble, statusId, pos);
|
||||
// A group is a different story object from the unit's onset. Publish one
|
||||
// canonical outbreak card instead of mutating every per-unit status key
|
||||
// into an identical crowd card. The latter left several changing cards in
|
||||
// the queue and forced repeated full-world presentation validation.
|
||||
if (TryRegisterOutbreak(subject, statusId, entry))
|
||||
{
|
||||
InterestRegistry.ForceExpireContaining(
|
||||
"status:" + statusId + ":",
|
||||
Time.unscaledTime);
|
||||
}
|
||||
}
|
||||
|
||||
private static float _lastTickAt = -999f;
|
||||
|
|
@ -498,5 +507,8 @@ public static class StatusOutbreakFeed
|
|||
registered.Label = EventReason.StatusOutbreak(ensemble);
|
||||
registered.ParticipantCount = ensemble.ParticipantCount;
|
||||
InterestScoring.ScoreCheap(registered);
|
||||
// ApplySticky runs after registry intake. Its generated crowd framing is
|
||||
// authoritative and ownership-preserving, so retain that approval.
|
||||
EventPresentability.MarkApproved(registered);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} "
|
||||
|
|
@ -167,16 +180,38 @@ public static class IdleHitchProbe
|
|||
+ $"{StoryScheduler.LastThreadScoreMs:0.0}/"
|
||||
+ $"{StoryScheduler.LastPendingCopyMs:0.0}/"
|
||||
+ $"{StoryScheduler.LastProposalMs:0.0} "
|
||||
+ $"selectorParts={EpisodeShotSelector.LastReplayFilterMs:0.0}/"
|
||||
+ $"{EpisodeShotSelector.LastPresentabilityMs:0.0}/"
|
||||
+ $"{EpisodeShotSelector.LastClassificationMs:0.0}/"
|
||||
+ $"{EpisodeShotSelector.LastScoringMs:0.0} "
|
||||
+ $"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;
|
||||
|
|
|
|||
|
|
@ -116,6 +116,25 @@ public sealed class InterestCandidate
|
|||
public string ScoreDetail = "";
|
||||
public readonly List<long> ParticipantIds = new List<long>(4);
|
||||
|
||||
// Replay-key memoization. The scheduler consults all three policies repeatedly while
|
||||
// candidates wait in the registry; rebuilding lowercase/prefixed strings every pass
|
||||
// created avoidable garbage-collection hitches in developed worlds.
|
||||
internal bool ReplayKeysCached;
|
||||
internal string ReplayCacheLabel = "";
|
||||
internal string ReplayCacheKey = "";
|
||||
internal string ReplayCacheAssetId = "";
|
||||
internal string ReplayCacheSource = "";
|
||||
internal string ReplayCacheStatusId = "";
|
||||
internal string ReplayCacheHappinessEffectId = "";
|
||||
internal string ReplayCacheActionId = "";
|
||||
internal long ReplayCacheSubjectId;
|
||||
internal InterestCompletionKind ReplayCacheCompletion;
|
||||
internal NarrativeFunction ReplayCacheFunction;
|
||||
internal bool ReplayCacheHasFunction;
|
||||
internal string CachedEditorialReplayKey = "";
|
||||
internal string CachedExactReplayKey = "";
|
||||
internal string CachedRoutineReplayKey = "";
|
||||
|
||||
// Compat forwarders - existing combat maintain/harness code.
|
||||
public int CombatPeakParticipants
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1135,7 +1135,7 @@ public static partial class InterestDirector
|
|||
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
|
||||
}
|
||||
|
||||
// Outbreak stickies are groups only - never "Outbreak - X (0)" / solo (1).
|
||||
// Outbreak stickies are groups only; solo carriers remain individual status beats.
|
||||
int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
|
||||
if (carriers < 2)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ public static partial class InterestDirector
|
|||
InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key);
|
||||
return false;
|
||||
}
|
||||
if (candidate != _current && InterestVariety.IsExactReplayCooled(candidate))
|
||||
{
|
||||
InterestDropLog.Record("exact_replay", candidate.Label ?? candidate.Key);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate != _current
|
||||
&& !StoryScheduler.MayUseCombatShot(candidate))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public static class InterestVariety
|
|||
public static float FixedDwellBeatCooldownSeconds =>
|
||||
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
|
||||
public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f);
|
||||
public static float ExactReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 90f);
|
||||
public static float RoutineReplayCooldownSeconds =>
|
||||
Mathf.Max(FixedDwellBeatCooldownSeconds, 120f);
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ public static class InterestVariety
|
|||
LastPickHadBothPools = false;
|
||||
_lastArcKey = "";
|
||||
_arcStreak = 0;
|
||||
CameraDirector.ClearReplayLedger();
|
||||
}
|
||||
|
||||
/// <summary>Harness: push an arc into the rarity window without a full selection.</summary>
|
||||
|
|
@ -186,6 +188,14 @@ public static class InterestVariety
|
|||
Time.unscaledTime + RoutineReplayCooldownSeconds);
|
||||
}
|
||||
|
||||
string exactKey = ExactReplayKey(chosen);
|
||||
if (!string.IsNullOrEmpty(exactKey))
|
||||
{
|
||||
StampCooldown(
|
||||
exactKey,
|
||||
Time.unscaledTime + ExactReplayCooldownSeconds);
|
||||
}
|
||||
|
||||
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
|
||||
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
|
||||
}
|
||||
|
|
@ -211,6 +221,21 @@ public static class InterestVariety
|
|||
return CooldownUntil.TryGetValue(key, out float until) && now < until;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prevent an identical player-facing sentence from being presented as a fresh cut,
|
||||
/// even when separate hooks generated different candidate keys.
|
||||
/// </summary>
|
||||
public static bool IsExactReplayCooled(InterestCandidate c, float now = -1f)
|
||||
{
|
||||
string key = ExactReplayKey(c);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (now < 0f) now = Time.unscaledTime;
|
||||
return CooldownUntil.TryGetValue(key, out float until) && now < until;
|
||||
}
|
||||
|
||||
public static bool HarnessProbeEditorialReplay(out string detail)
|
||||
{
|
||||
Clear();
|
||||
|
|
@ -224,7 +249,7 @@ public static class InterestVariety
|
|||
var replay = new InterestCandidate
|
||||
{
|
||||
Key = "combat:pair:1:2:refresh",
|
||||
Label = "Duel - Alpha vs Beta",
|
||||
Label = "Mass - Beta (3) vs Alpha (5)",
|
||||
Completion = InterestCompletionKind.CombatActive
|
||||
};
|
||||
var different = new InterestCandidate
|
||||
|
|
@ -241,12 +266,39 @@ public static class InterestVariety
|
|||
StatusId = "sleeping"
|
||||
};
|
||||
bool sameCooled = IsEditorialReplayCooled(replay);
|
||||
bool structuralExactCooled = IsExactReplayCooled(replay);
|
||||
bool differentOpen = !IsEditorialReplayCooled(different);
|
||||
bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture);
|
||||
bool ok = sameCooled && differentOpen && textureUsesBeatPolicy;
|
||||
var life = new InterestCandidate
|
||||
{
|
||||
Key = "relationship:1:2:first",
|
||||
Label = "Norron mates with Onaano",
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
NoteSelection(life, updateMix: false);
|
||||
var lifeReplay = new InterestCandidate
|
||||
{
|
||||
Key = "relationship:1:2:second",
|
||||
Label = "Norron mates with Onaano",
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
var lifeProgress = new InterestCandidate
|
||||
{
|
||||
Key = "relationship:1:2:home",
|
||||
Label = "Norron finds a home",
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
bool exactLifeCooled = IsExactReplayCooled(lifeReplay);
|
||||
bool progressedLifeOpen = !IsExactReplayCooled(lifeProgress);
|
||||
bool ok = sameCooled && structuralExactCooled
|
||||
&& differentOpen && textureUsesBeatPolicy
|
||||
&& exactLifeCooled && progressedLifeOpen;
|
||||
detail = "sameCooled=" + sameCooled
|
||||
+ " structuralExact=" + structuralExactCooled
|
||||
+ " differentOpen=" + differentOpen
|
||||
+ " textureUsesBeatPolicy=" + textureUsesBeatPolicy;
|
||||
+ " textureUsesBeatPolicy=" + textureUsesBeatPolicy
|
||||
+ " exactLife=" + exactLifeCooled
|
||||
+ " progressedLife=" + progressedLifeOpen;
|
||||
Clear();
|
||||
return ok;
|
||||
}
|
||||
|
|
@ -319,6 +371,43 @@ public static class InterestVariety
|
|||
return ok;
|
||||
}
|
||||
|
||||
public static bool HarnessProbeFamilyCoalescing(out string detail)
|
||||
{
|
||||
Clear();
|
||||
var birthFact = new InterestCandidate
|
||||
{
|
||||
SubjectId = 41,
|
||||
RelatedId = 43,
|
||||
AssetId = "add_child",
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
NoteSelection(birthFact, updateMix: false);
|
||||
|
||||
var sameParentReaction = new InterestCandidate
|
||||
{
|
||||
SubjectId = 41,
|
||||
HappinessEffectId = "just_had_child",
|
||||
AssetId = "just_had_child",
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
var otherParentReaction = new InterestCandidate
|
||||
{
|
||||
SubjectId = 42,
|
||||
HappinessEffectId = "just_had_child",
|
||||
AssetId = "just_had_child",
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
|
||||
bool echoCooled = IsBeatCooled(sameParentReaction);
|
||||
bool unrelatedOpen = !IsBeatCooled(otherParentReaction);
|
||||
bool ok = echoCooled && unrelatedOpen;
|
||||
detail = "echo=" + echoCooled
|
||||
+ " unrelated=" + unrelatedOpen
|
||||
+ " pass=" + ok;
|
||||
Clear();
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score to subtract when this candidate matches the last shown variety arc.
|
||||
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
|
||||
|
|
@ -795,6 +884,12 @@ public static class InterestVariety
|
|||
continue;
|
||||
}
|
||||
|
||||
if (IsExactReplayCooled(c, now))
|
||||
{
|
||||
InterestDropLog.Record("exact_replay", c.Label ?? c.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsRoutineReplayCooled(c, now))
|
||||
{
|
||||
InterestDropLog.Record("routine_replay", c.Label ?? c.Key);
|
||||
|
|
@ -1008,6 +1103,14 @@ public static class InterestVariety
|
|||
{
|
||||
return "";
|
||||
}
|
||||
EnsureReplayKeys(c);
|
||||
return c.CachedEditorialReplayKey;
|
||||
}
|
||||
|
||||
private static string BuildEditorialReplayKey(
|
||||
InterestCandidate c,
|
||||
string normalizedLabel)
|
||||
{
|
||||
|
||||
bool stickyCard = c.Completion == InterestCompletionKind.CombatActive
|
||||
|| c.Completion == InterestCompletionKind.WarFront
|
||||
|
|
@ -1021,22 +1124,37 @@ public static class InterestVariety
|
|||
return "";
|
||||
}
|
||||
|
||||
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrEmpty(label))
|
||||
if (!string.IsNullOrEmpty(normalizedLabel))
|
||||
{
|
||||
return "replay:label:" + label;
|
||||
return "replay:label:" + normalizedLabel;
|
||||
}
|
||||
|
||||
string key = (c.Key ?? "").Trim().ToLowerInvariant();
|
||||
return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key;
|
||||
}
|
||||
|
||||
private static string ExactReplayKey(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
EnsureReplayKeys(c);
|
||||
return c.CachedExactReplayKey;
|
||||
}
|
||||
|
||||
private static string RoutineReplayKey(InterestCandidate c)
|
||||
{
|
||||
if (c == null || c.SubjectId == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
EnsureReplayKeys(c);
|
||||
return c.CachedRoutineReplayKey;
|
||||
}
|
||||
|
||||
private static string BuildRoutineReplayKey(InterestCandidate c)
|
||||
{
|
||||
|
||||
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
|
||||
if (function != NarrativeFunction.CharacterTexture
|
||||
|
|
@ -1057,6 +1175,53 @@ public static class InterestVariety
|
|||
: "routine:" + c.SubjectId + ":" + family;
|
||||
}
|
||||
|
||||
private static void EnsureReplayKeys(InterestCandidate c)
|
||||
{
|
||||
string label = c.Label ?? "";
|
||||
string key = c.Key ?? "";
|
||||
string assetId = c.AssetId ?? "";
|
||||
string source = c.Source ?? "";
|
||||
string statusId = c.StatusId ?? "";
|
||||
string happinessId = c.HappinessEffectId ?? "";
|
||||
string actionId = c.NarrativePresentation?.ActionId ?? "";
|
||||
if (c.ReplayKeysCached
|
||||
&& c.ReplayCacheSubjectId == c.SubjectId
|
||||
&& c.ReplayCacheCompletion == c.Completion
|
||||
&& c.ReplayCacheFunction == c.NarrativeFunction
|
||||
&& c.ReplayCacheHasFunction == c.HasNarrativeFunction
|
||||
&& string.Equals(c.ReplayCacheLabel, label, StringComparison.Ordinal)
|
||||
&& string.Equals(c.ReplayCacheKey, key, StringComparison.Ordinal)
|
||||
&& string.Equals(c.ReplayCacheAssetId, assetId, StringComparison.Ordinal)
|
||||
&& string.Equals(c.ReplayCacheSource, source, StringComparison.Ordinal)
|
||||
&& string.Equals(c.ReplayCacheStatusId, statusId, StringComparison.Ordinal)
|
||||
&& string.Equals(
|
||||
c.ReplayCacheHappinessEffectId, happinessId, StringComparison.Ordinal)
|
||||
&& string.Equals(c.ReplayCacheActionId, actionId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
c.ReplayKeysCached = true;
|
||||
c.ReplayCacheLabel = label;
|
||||
c.ReplayCacheKey = key;
|
||||
c.ReplayCacheAssetId = assetId;
|
||||
c.ReplayCacheSource = source;
|
||||
c.ReplayCacheStatusId = statusId;
|
||||
c.ReplayCacheHappinessEffectId = happinessId;
|
||||
c.ReplayCacheActionId = actionId;
|
||||
c.ReplayCacheSubjectId = c.SubjectId;
|
||||
c.ReplayCacheCompletion = c.Completion;
|
||||
c.ReplayCacheFunction = c.NarrativeFunction;
|
||||
c.ReplayCacheHasFunction = c.HasNarrativeFunction;
|
||||
|
||||
string normalizedLabel = EventReason.ReplayStructureKey(label);
|
||||
c.CachedEditorialReplayKey = BuildEditorialReplayKey(c, normalizedLabel);
|
||||
c.CachedExactReplayKey = string.IsNullOrEmpty(normalizedLabel)
|
||||
? ""
|
||||
: "exact:label:" + normalizedLabel;
|
||||
c.CachedRoutineReplayKey = BuildRoutineReplayKey(c);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
|
||||
/// </summary>
|
||||
|
|
@ -1839,6 +2004,15 @@ public static class InterestVariety
|
|||
yield return "beat:life:love:world";
|
||||
}
|
||||
|
||||
// The relationship feed reports the concrete birth first ("gains a child");
|
||||
// happiness can report the same parent's reaction a frame later. They are one
|
||||
// family chapter, so showing the fact suppresses that immediate emotional echo
|
||||
// without cooling unrelated parents or later births world-wide.
|
||||
if (string.Equals(asset, "add_child", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return "beat:life:family:" + subject;
|
||||
}
|
||||
|
||||
if (EventCatalog.Decision.IsLifeIntentDecision(asset))
|
||||
{
|
||||
yield return "beat:asset:" + asset + ":world";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
|
@ -16,32 +17,90 @@ public sealed class EpisodeShotProposal
|
|||
/// <summary>Selects the best presentable shot that can explain its place in the active episode.</summary>
|
||||
public static class EpisodeShotSelector
|
||||
{
|
||||
public static float LastReplayFilterMs { get; private set; }
|
||||
public static float LastPresentabilityMs { get; private set; }
|
||||
public static float LastClassificationMs { get; private set; }
|
||||
public static float LastScoringMs { get; private set; }
|
||||
public static int LastPresentabilityChecks { get; private set; }
|
||||
public static string LastSlowCandidateKey { get; private set; } = "";
|
||||
public static float LastSlowCandidateMs { get; private set; }
|
||||
|
||||
public static EpisodeShotProposal Pick(
|
||||
IList<InterestCandidate> candidates,
|
||||
EpisodePlan episode,
|
||||
NarrativeThread thread,
|
||||
bool requirePresentable = true)
|
||||
{
|
||||
ResetTiming();
|
||||
if (candidates == null || candidates.Count == 0 || episode == null) return null;
|
||||
EpisodeShotProposal bestRelated = null;
|
||||
EpisodeShotProposal bestInterrupt = null;
|
||||
bool trace = IdleHitchProbe.Enabled;
|
||||
InterestCandidate bestRelatedCandidate = null;
|
||||
NarrativeInterruptClass bestRelatedKind = NarrativeInterruptClass.OrdinaryInteresting;
|
||||
EpisodeShotRole bestRelatedRole = EpisodeShotRole.Unknown;
|
||||
string bestRelatedSemanticKey = "";
|
||||
float bestRelatedScore = float.MinValue;
|
||||
|
||||
InterestCandidate bestInterruptCandidate = null;
|
||||
NarrativeInterruptClass bestInterruptKind = NarrativeInterruptClass.OrdinaryInteresting;
|
||||
EpisodeShotRole bestInterruptRole = EpisodeShotRole.Unknown;
|
||||
float bestInterruptScore = float.MinValue;
|
||||
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
InterestCandidate c = candidates[i];
|
||||
bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase);
|
||||
if (c == null || c.Selected
|
||||
|| InterestVariety.IsEditorialReplayCooled(c)
|
||||
|| InterestVariety.IsRoutineReplayCooled(c)
|
||||
|| !StoryScheduler.MayUseCombatShot(episode, c)
|
||||
|| (requirePresentable && !harness && !EventPresentability.WouldShow(c))) continue;
|
||||
if (c == null || c.Selected) continue;
|
||||
|
||||
long stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
|
||||
bool replayCooled =
|
||||
InterestVariety.IsEditorialReplayCooled(c)
|
||||
|| InterestVariety.IsExactReplayCooled(c)
|
||||
|| InterestVariety.IsRoutineReplayCooled(c);
|
||||
if (trace)
|
||||
{
|
||||
LastReplayFilterMs += ElapsedMs(stageStarted);
|
||||
}
|
||||
if (replayCooled || !StoryScheduler.MayUseCombatShot(episode, c)) continue;
|
||||
|
||||
if (requirePresentable && !harness)
|
||||
{
|
||||
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
|
||||
bool presentable = EventPresentability.WouldShow(c);
|
||||
if (trace)
|
||||
{
|
||||
float elapsed = ElapsedMs(stageStarted);
|
||||
LastPresentabilityMs += elapsed;
|
||||
LastPresentabilityChecks++;
|
||||
if (elapsed > LastSlowCandidateMs)
|
||||
{
|
||||
LastSlowCandidateMs = elapsed;
|
||||
LastSlowCandidateKey = c.Key ?? "";
|
||||
}
|
||||
}
|
||||
if (!presentable) continue;
|
||||
}
|
||||
|
||||
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
|
||||
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
|
||||
if (function == NarrativeFunction.Noise) continue;
|
||||
if (function == NarrativeFunction.Noise)
|
||||
{
|
||||
if (trace)
|
||||
{
|
||||
LastClassificationMs += ElapsedMs(stageStarted);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
|
||||
EpisodeShotRole role = RoleFor(c, function, kind, episode);
|
||||
string semanticKey = SemanticKey(c, role);
|
||||
bool related = kind == NarrativeInterruptClass.RelatedEscalation;
|
||||
bool advances = related && AdvancesEpisode(episode, role);
|
||||
string semanticKey = related && !advances ? SemanticKey(c, role) : "";
|
||||
if (trace)
|
||||
{
|
||||
LastClassificationMs += ElapsedMs(stageStarted);
|
||||
}
|
||||
if (related
|
||||
&& !AdvancesEpisode(episode, role)
|
||||
&& !advances
|
||||
&& episode.RecentSemanticKeys.Contains(semanticKey))
|
||||
{
|
||||
continue;
|
||||
|
|
@ -53,36 +112,91 @@ public static class EpisodeShotSelector
|
|||
continue;
|
||||
}
|
||||
|
||||
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
|
||||
float score = EditorialScore(c, episode, kind, function, role);
|
||||
var proposal = new EpisodeShotProposal
|
||||
if (trace)
|
||||
{
|
||||
Candidate = c,
|
||||
InterruptClass = kind,
|
||||
EditorialScore = score,
|
||||
Role = role,
|
||||
SemanticKey = semanticKey,
|
||||
Reason = Reason(kind) + ":" + role
|
||||
};
|
||||
LastScoringMs += ElapsedMs(stageStarted);
|
||||
}
|
||||
if (kind == NarrativeInterruptClass.RelatedEscalation)
|
||||
{
|
||||
if (bestRelated == null || score > bestRelated.EditorialScore) bestRelated = proposal;
|
||||
if (bestRelatedCandidate == null || score > bestRelatedScore)
|
||||
{
|
||||
bestRelatedCandidate = c;
|
||||
bestRelatedKind = kind;
|
||||
bestRelatedRole = role;
|
||||
bestRelatedSemanticKey = string.IsNullOrEmpty(semanticKey)
|
||||
? SemanticKey(c, role)
|
||||
: semanticKey;
|
||||
bestRelatedScore = score;
|
||||
}
|
||||
}
|
||||
else if (InterruptPolicy.MayInterrupt(kind)
|
||||
&& (bestInterrupt == null || score > bestInterrupt.EditorialScore))
|
||||
&& (bestInterruptCandidate == null || score > bestInterruptScore))
|
||||
{
|
||||
bestInterrupt = proposal;
|
||||
bestInterruptCandidate = c;
|
||||
bestInterruptKind = kind;
|
||||
bestInterruptRole = role;
|
||||
bestInterruptScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
// A productive related development owns the episode. Critical/payoff material interrupts
|
||||
// only when no related shot is ready, or when its editorial advantage is decisive.
|
||||
if (bestRelated != null
|
||||
&& (bestInterrupt == null || bestInterrupt.EditorialScore < bestRelated.EditorialScore + 25f))
|
||||
if (bestRelatedCandidate != null
|
||||
&& (bestInterruptCandidate == null || bestInterruptScore < bestRelatedScore + 25f))
|
||||
{
|
||||
return bestRelated;
|
||||
return BuildProposal(
|
||||
bestRelatedCandidate,
|
||||
bestRelatedKind,
|
||||
bestRelatedRole,
|
||||
bestRelatedSemanticKey,
|
||||
bestRelatedScore);
|
||||
}
|
||||
|
||||
return bestInterrupt ?? bestRelated;
|
||||
if (bestInterruptCandidate != null)
|
||||
{
|
||||
return BuildProposal(
|
||||
bestInterruptCandidate,
|
||||
bestInterruptKind,
|
||||
bestInterruptRole,
|
||||
SemanticKey(bestInterruptCandidate, bestInterruptRole),
|
||||
bestInterruptScore);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ResetTiming()
|
||||
{
|
||||
LastReplayFilterMs = 0f;
|
||||
LastPresentabilityMs = 0f;
|
||||
LastClassificationMs = 0f;
|
||||
LastScoringMs = 0f;
|
||||
LastPresentabilityChecks = 0;
|
||||
LastSlowCandidateKey = "";
|
||||
LastSlowCandidateMs = 0f;
|
||||
}
|
||||
|
||||
private static float ElapsedMs(long started) =>
|
||||
(float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency);
|
||||
|
||||
private static EpisodeShotProposal BuildProposal(
|
||||
InterestCandidate candidate,
|
||||
NarrativeInterruptClass kind,
|
||||
EpisodeShotRole role,
|
||||
string semanticKey,
|
||||
float score)
|
||||
{
|
||||
return new EpisodeShotProposal
|
||||
{
|
||||
Candidate = candidate,
|
||||
InterruptClass = kind,
|
||||
EditorialScore = score,
|
||||
Role = role,
|
||||
SemanticKey = semanticKey ?? "",
|
||||
Reason = Reason(kind) + ":" + role
|
||||
};
|
||||
}
|
||||
|
||||
private static float EditorialScore(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
|
|
@ -51,4 +52,53 @@ public static class NarrativeEventStore
|
|||
Events[record.Id] = record;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep the event side of the runtime graph aligned with retained developments.
|
||||
/// Recent unprojected observations fill any remaining room so dedupe remains useful.
|
||||
/// </summary>
|
||||
internal static int Compact(HashSet<string> retainedDevelopmentIds, int max)
|
||||
{
|
||||
if (Events.Count <= max || max <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var retained = new HashSet<string>(StringComparer.Ordinal);
|
||||
var ranked = new List<NarrativeEventRecord>(Events.Values);
|
||||
ranked.Sort((a, b) =>
|
||||
{
|
||||
bool aLinked = a != null
|
||||
&& retainedDevelopmentIds != null
|
||||
&& retainedDevelopmentIds.Contains(a.DevelopmentId ?? "");
|
||||
bool bLinked = b != null
|
||||
&& retainedDevelopmentIds != null
|
||||
&& retainedDevelopmentIds.Contains(b.DevelopmentId ?? "");
|
||||
if (aLinked != bLinked) return aLinked ? -1 : 1;
|
||||
float aAt = a?.ObservedAt ?? float.MinValue;
|
||||
float bAt = b?.ObservedAt ?? float.MinValue;
|
||||
int observed = bAt.CompareTo(aAt);
|
||||
if (observed != 0) return observed;
|
||||
return (b?.WorldTime ?? double.MinValue).CompareTo(
|
||||
a?.WorldTime ?? double.MinValue);
|
||||
});
|
||||
|
||||
for (int i = 0; i < ranked.Count && retained.Count < max; i++)
|
||||
{
|
||||
NarrativeEventRecord record = ranked[i];
|
||||
if (record != null && !string.IsNullOrEmpty(record.Id))
|
||||
{
|
||||
retained.Add(record.Id);
|
||||
}
|
||||
}
|
||||
|
||||
int before = Events.Count;
|
||||
var remove = new List<string>();
|
||||
foreach (string id in Events.Keys)
|
||||
{
|
||||
if (!retained.Contains(id)) remove.Add(id);
|
||||
}
|
||||
for (int i = 0; i < remove.Count; i++) Events.Remove(remove[i]);
|
||||
return Mathf.Max(0, before - Events.Count);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,12 +90,13 @@ public static class NarrativeExposureLedger
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two routine combat cuts may establish a conflict; another story family must then
|
||||
/// receive space. World-critical combat bypasses this global exposure cap.
|
||||
/// Two combat cuts may establish and escalate a conflict; another story family must
|
||||
/// then receive space. Criticality affects which combat cut wins, not whether combat
|
||||
/// may exceed the global story-first exposure budget.
|
||||
/// </summary>
|
||||
public static bool MayPresent(InterestCandidate candidate)
|
||||
{
|
||||
if (!IsCombat(candidate) || candidate.EventStrength >= 95f)
|
||||
if (!IsCombat(candidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
@ -150,18 +151,19 @@ public static class NarrativeExposureLedger
|
|||
NotePresented(combat);
|
||||
NotePresented(combat);
|
||||
bool routineBlocked = !MayPresent(combat);
|
||||
bool criticalAllowed = MayPresent(criticalCombat);
|
||||
bool criticalBounded = !MayPresent(criticalCombat);
|
||||
for (int i = 0; i < 5; i++) NotePresented(family);
|
||||
bool routineReopened = MayPresent(combat);
|
||||
bool ok = underCovered > prolific
|
||||
&& repetition > 0f
|
||||
&& diverse == 0f
|
||||
&& routineBlocked
|
||||
&& criticalAllowed
|
||||
&& criticalBounded
|
||||
&& routineReopened;
|
||||
detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0")
|
||||
+ " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0")
|
||||
+ " combat=" + routineBlocked + "/" + criticalAllowed + "/" + routineReopened
|
||||
+ " combat=" + routineBlocked + "/" + criticalBounded
|
||||
+ "/" + routineReopened
|
||||
+ " pass=" + ok;
|
||||
Clear();
|
||||
return ok;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -137,6 +138,8 @@ public sealed class NarrativeCastRole
|
|||
/// <summary>Durable unresolved question centered on one life.</summary>
|
||||
public sealed class NarrativeThread
|
||||
{
|
||||
private const int MaxRetainedCast = 16;
|
||||
|
||||
public string Id = "";
|
||||
public NarrativeThreadKind Kind;
|
||||
public long ProtagonistId;
|
||||
|
|
@ -177,6 +180,18 @@ public sealed class NarrativeThread
|
|||
{
|
||||
if (id == 0 || HasCast(id)) return;
|
||||
Cast.Add(new NarrativeCastRole { CharacterId = id, Role = role ?? "" });
|
||||
while (Cast.Count > MaxRetainedCast)
|
||||
{
|
||||
int removeAt = 0;
|
||||
while (removeAt < Cast.Count
|
||||
&& Cast[removeAt] != null
|
||||
&& Cast[removeAt].CharacterId == ProtagonistId)
|
||||
{
|
||||
removeAt++;
|
||||
}
|
||||
if (removeAt >= Cast.Count) break;
|
||||
Cast.RemoveAt(removeAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ public static class NarrativeStoryStore
|
|||
private const int ConsequencesPerCharacter = 24;
|
||||
private const int OpenThreadsPerCharacter = 16;
|
||||
private const int ResolvedThreadsPerCharacter = 24;
|
||||
private const int DevelopmentsPerThread = 16;
|
||||
private const int MaxRuntimeEvents = 2200;
|
||||
private const int MaxRuntimeDevelopments = 2000;
|
||||
private const int MaxRuntimeThreads = 650;
|
||||
private const int MaxRuntimeConsequences = 1200;
|
||||
private const int MaxRuntimeCharacters = 2000;
|
||||
private const float RuntimeCompactionIntervalSeconds = 60f;
|
||||
internal const int SchedulerCandidateLimit = 96;
|
||||
internal const int SchedulerCopyLimit = 128;
|
||||
private static readonly Dictionary<string, NarrativeDevelopment> Developments =
|
||||
|
|
@ -33,11 +40,15 @@ public static class NarrativeStoryStore
|
|||
private static readonly Dictionary<string, HashSet<long>> ConflictCastByThread =
|
||||
new Dictionary<string, HashSet<long>>(StringComparer.Ordinal);
|
||||
private static readonly List<string> IdScratch = new List<string>(SchedulerCandidateLimit);
|
||||
private static float _nextRuntimeCompactionAt;
|
||||
|
||||
public static int Revision { get; private set; }
|
||||
public static int DevelopmentCount => Developments.Count;
|
||||
public static int ThreadCount => Threads.Count;
|
||||
public static int CharacterCount => Characters.Count;
|
||||
public static int ConsequenceCount => Consequences.Count;
|
||||
public static float LastCompactionMs { get; private set; }
|
||||
public static int CompactionCount { get; private set; }
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
|
|
@ -52,6 +63,9 @@ public static class NarrativeStoryStore
|
|||
OpenConflictsByCharacter.Clear();
|
||||
ConflictCastByThread.Clear();
|
||||
IdScratch.Clear();
|
||||
_nextRuntimeCompactionAt = 0f;
|
||||
LastCompactionMs = 0f;
|
||||
CompactionCount = 0;
|
||||
NarrativeEventStore.Clear();
|
||||
Revision++;
|
||||
StoryScheduler.Clear();
|
||||
|
|
@ -189,14 +203,19 @@ public static class NarrativeStoryStore
|
|||
}
|
||||
|
||||
Developments[development.Id] = development;
|
||||
CharacterStoryState subject = GetOrCreateCharacter(development.SubjectId, development.Subject);
|
||||
TouchDevelopment(subject, development);
|
||||
if (development.OtherId != 0)
|
||||
if (ShouldProjectDevelopment(development))
|
||||
{
|
||||
GetOrCreateCharacter(development.OtherId, development.Other);
|
||||
}
|
||||
CharacterStoryState subject =
|
||||
GetOrCreateCharacter(development.SubjectId, development.Subject);
|
||||
TouchDevelopment(subject, development);
|
||||
if (development.OtherId != 0)
|
||||
{
|
||||
GetOrCreateCharacter(development.OtherId, development.Other);
|
||||
}
|
||||
|
||||
NarrativeThreadRules.Apply(development);
|
||||
NarrativeThreadRules.Apply(development);
|
||||
}
|
||||
MaybeCompactRuntimeGraph();
|
||||
Revision++;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -236,6 +255,7 @@ public static class NarrativeStoryStore
|
|||
thread.UpdatedAt = development.OccurredAt;
|
||||
thread.LatestMeaningfulChangeId = development.Id;
|
||||
if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id);
|
||||
TrimThreadDevelopmentHistory(thread);
|
||||
thread.AddCast(protagonistId, "protagonist");
|
||||
if (development.OtherId != 0) thread.AddCast(development.OtherId, CastRole(development.Kind));
|
||||
thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function));
|
||||
|
|
@ -252,6 +272,18 @@ public static class NarrativeStoryStore
|
|||
return thread;
|
||||
}
|
||||
|
||||
private static void TrimThreadDevelopmentHistory(NarrativeThread thread)
|
||||
{
|
||||
if (thread == null) return;
|
||||
while (thread.DevelopmentIds.Count > DevelopmentsPerThread)
|
||||
{
|
||||
// The opening evidence remains useful for episode/Legacy context. Evict the
|
||||
// oldest middle beat while retaining the newest state transition.
|
||||
int removeAt = thread.DevelopmentIds.Count > 1 ? 1 : 0;
|
||||
thread.DevelopmentIds.RemoveAt(removeAt);
|
||||
}
|
||||
}
|
||||
|
||||
public static void AddConsequence(CharacterConsequence consequence)
|
||||
{
|
||||
if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return;
|
||||
|
|
@ -494,6 +526,7 @@ public static class NarrativeStoryStore
|
|||
int repaired = 0;
|
||||
foreach (NarrativeThread thread in Threads.Values)
|
||||
{
|
||||
TrimThreadDevelopmentHistory(thread);
|
||||
if (thread != null
|
||||
&& thread.Kind == NarrativeThreadKind.Founding
|
||||
&& thread.IsOpen
|
||||
|
|
@ -559,6 +592,416 @@ public static class NarrativeStoryStore
|
|||
return repaired;
|
||||
}
|
||||
|
||||
private static void MaybeCompactRuntimeGraph()
|
||||
{
|
||||
bool overHighWater =
|
||||
NarrativeEventStore.Count > MaxRuntimeEvents * 3 / 2
|
||||
|| Developments.Count > MaxRuntimeDevelopments * 3 / 2
|
||||
|| Threads.Count > MaxRuntimeThreads * 3 / 2
|
||||
|| Consequences.Count > MaxRuntimeConsequences * 3 / 2
|
||||
|| Characters.Count > MaxRuntimeCharacters * 3 / 2;
|
||||
if (!overHighWater)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
bool emergency =
|
||||
NarrativeEventStore.Count > MaxRuntimeEvents * 2
|
||||
|| Developments.Count > MaxRuntimeDevelopments * 2
|
||||
|| Threads.Count > MaxRuntimeThreads * 2
|
||||
|| Consequences.Count > MaxRuntimeConsequences * 2
|
||||
|| Characters.Count > MaxRuntimeCharacters * 2;
|
||||
if (!emergency && now < _nextRuntimeCompactionAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CompactRuntimeGraph(force: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|| !DefersAnonymousGraphProjection(development.Kind)
|
||||
|| (!string.IsNullOrEmpty(development.EvidenceSource)
|
||||
&& development.EvidenceSource.StartsWith(
|
||||
"harness", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (LifeSagaRoster.IsMc(development.SubjectId)
|
||||
|| LifeSagaRoster.IsPrefer(development.SubjectId)
|
||||
|| LifeSagaRoster.IsMcCast(development.SubjectId)
|
||||
|| LifeSagaRoster.IsMc(development.OtherId)
|
||||
|| LifeSagaRoster.IsPrefer(development.OtherId)
|
||||
|| LifeSagaRoster.IsMcCast(development.OtherId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
EpisodePlan episode = StoryScheduler.ActiveEpisode;
|
||||
if (episode == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (episode.ProtagonistId == development.SubjectId
|
||||
|| episode.ProtagonistId == development.OtherId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
NarrativeThread active = Thread(episode.ThreadId);
|
||||
return active != null
|
||||
&& (active.HasCast(development.SubjectId)
|
||||
|| active.HasCast(development.OtherId));
|
||||
}
|
||||
|
||||
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
|
||||
/// worlds from retaining every anonymous one-shot thread until the next reload.
|
||||
/// </summary>
|
||||
private static bool CompactRuntimeGraph(bool force)
|
||||
{
|
||||
bool needsCompaction =
|
||||
NarrativeEventStore.Count > MaxRuntimeEvents
|
||||
|| Developments.Count > MaxRuntimeDevelopments
|
||||
|| Threads.Count > MaxRuntimeThreads
|
||||
|| Consequences.Count > MaxRuntimeConsequences
|
||||
|| Characters.Count > MaxRuntimeCharacters;
|
||||
if (!force && !needsCompaction)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
int beforeEvents = NarrativeEventStore.Count;
|
||||
int beforeDevelopments = Developments.Count;
|
||||
int beforeThreads = Threads.Count;
|
||||
int beforeConsequences = Consequences.Count;
|
||||
int beforeCharacters = Characters.Count;
|
||||
float now = Time.unscaledTime;
|
||||
|
||||
var protectedCharacters = new HashSet<long>();
|
||||
LifeSagaRoster.CopySlots(RosterScratch);
|
||||
for (int i = 0; i < RosterScratch.Count; i++)
|
||||
{
|
||||
long id = RosterScratch[i]?.UnitId ?? 0;
|
||||
if (id != 0) protectedCharacters.Add(id);
|
||||
}
|
||||
RosterScratch.Clear();
|
||||
|
||||
EpisodePlan episode = StoryScheduler.ActiveEpisode;
|
||||
if (episode != null && episode.ProtagonistId != 0)
|
||||
{
|
||||
protectedCharacters.Add(episode.ProtagonistId);
|
||||
}
|
||||
|
||||
var retainedThreadIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (episode != null && !string.IsNullOrEmpty(episode.ThreadId)
|
||||
&& Threads.ContainsKey(episode.ThreadId))
|
||||
{
|
||||
retainedThreadIds.Add(episode.ThreadId);
|
||||
}
|
||||
|
||||
foreach (long characterId in protectedCharacters)
|
||||
{
|
||||
CharacterStoryState state = Character(characterId);
|
||||
if (state == null) continue;
|
||||
AddExistingIds(state.OpenThreadIds, Threads, retainedThreadIds);
|
||||
AddExistingIds(state.ResolvedThreadIds, Threads, retainedThreadIds);
|
||||
}
|
||||
|
||||
ThreadScratch.Clear();
|
||||
foreach (NarrativeThread thread in Threads.Values)
|
||||
{
|
||||
if (thread != null && !retainedThreadIds.Contains(thread.Id))
|
||||
{
|
||||
ThreadScratch.Add(thread);
|
||||
}
|
||||
}
|
||||
ThreadScratch.Sort((a, b) =>
|
||||
ThreadRetentionScore(b, now).CompareTo(ThreadRetentionScore(a, now)));
|
||||
for (int i = 0;
|
||||
i < ThreadScratch.Count && retainedThreadIds.Count < MaxRuntimeThreads;
|
||||
i++)
|
||||
{
|
||||
retainedThreadIds.Add(ThreadScratch[i].Id);
|
||||
}
|
||||
ThreadScratch.Clear();
|
||||
|
||||
var retainedDevelopmentIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (string threadId in retainedThreadIds)
|
||||
{
|
||||
NarrativeThread thread = Thread(threadId);
|
||||
AddExistingId(thread?.LatestMeaningfulChangeId, Developments, retainedDevelopmentIds);
|
||||
}
|
||||
foreach (long characterId in protectedCharacters)
|
||||
{
|
||||
CharacterStoryState state = Character(characterId);
|
||||
if (state == null) continue;
|
||||
AddExistingIds(
|
||||
state.RecentDevelopmentIds,
|
||||
Developments,
|
||||
retainedDevelopmentIds,
|
||||
MaxRuntimeDevelopments);
|
||||
}
|
||||
|
||||
var rankedConsequences = new List<CharacterConsequence>(Consequences.Values);
|
||||
rankedConsequences.Sort((a, b) =>
|
||||
{
|
||||
bool aProtected = a != null && protectedCharacters.Contains(a.CharacterId);
|
||||
bool bProtected = b != null && protectedCharacters.Contains(b.CharacterId);
|
||||
if (aProtected != bProtected) return aProtected ? -1 : 1;
|
||||
int importance = (b?.Importance ?? float.MinValue).CompareTo(
|
||||
a?.Importance ?? float.MinValue);
|
||||
if (importance != 0) return importance;
|
||||
return (b?.OccurredAt ?? float.MinValue).CompareTo(
|
||||
a?.OccurredAt ?? float.MinValue);
|
||||
});
|
||||
|
||||
var retainedConsequenceIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (int i = 0;
|
||||
i < rankedConsequences.Count
|
||||
&& retainedConsequenceIds.Count < MaxRuntimeConsequences;
|
||||
i++)
|
||||
{
|
||||
CharacterConsequence consequence = rankedConsequences[i];
|
||||
if (consequence == null || string.IsNullOrEmpty(consequence.Id)
|
||||
|| string.IsNullOrEmpty(consequence.DevelopmentId)
|
||||
|| !Developments.ContainsKey(consequence.DevelopmentId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!retainedDevelopmentIds.Contains(consequence.DevelopmentId)
|
||||
&& retainedDevelopmentIds.Count >= MaxRuntimeDevelopments)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
retainedDevelopmentIds.Add(consequence.DevelopmentId);
|
||||
retainedConsequenceIds.Add(consequence.Id);
|
||||
}
|
||||
|
||||
var rankedDevelopments = new List<NarrativeDevelopment>(Developments.Values);
|
||||
rankedDevelopments.Sort((a, b) =>
|
||||
{
|
||||
float aScore = DevelopmentRetentionScore(a);
|
||||
float bScore = DevelopmentRetentionScore(b);
|
||||
int score = bScore.CompareTo(aScore);
|
||||
if (score != 0) return score;
|
||||
return (b?.OccurredAt ?? float.MinValue).CompareTo(
|
||||
a?.OccurredAt ?? float.MinValue);
|
||||
});
|
||||
for (int i = 0;
|
||||
i < rankedDevelopments.Count
|
||||
&& retainedDevelopmentIds.Count < MaxRuntimeDevelopments;
|
||||
i++)
|
||||
{
|
||||
NarrativeDevelopment development = rankedDevelopments[i];
|
||||
if (development != null && !string.IsNullOrEmpty(development.Id))
|
||||
{
|
||||
retainedDevelopmentIds.Add(development.Id);
|
||||
}
|
||||
}
|
||||
|
||||
RemoveUnretained(Threads, retainedThreadIds);
|
||||
RemoveUnretained(Consequences, retainedConsequenceIds);
|
||||
RemoveUnretained(Developments, retainedDevelopmentIds);
|
||||
|
||||
foreach (NarrativeThread thread in Threads.Values)
|
||||
{
|
||||
if (thread == null) continue;
|
||||
thread.DevelopmentIds.RemoveAll(id => !retainedDevelopmentIds.Contains(id));
|
||||
if (!string.IsNullOrEmpty(thread.LatestMeaningfulChangeId)
|
||||
&& retainedDevelopmentIds.Contains(thread.LatestMeaningfulChangeId)
|
||||
&& !thread.DevelopmentIds.Contains(thread.LatestMeaningfulChangeId))
|
||||
{
|
||||
thread.DevelopmentIds.Add(thread.LatestMeaningfulChangeId);
|
||||
}
|
||||
if (!retainedDevelopmentIds.Contains(thread.OpenedByDevelopmentId))
|
||||
{
|
||||
thread.OpenedByDevelopmentId = thread.DevelopmentIds.Count > 0
|
||||
? thread.DevelopmentIds[0]
|
||||
: thread.LatestMeaningfulChangeId;
|
||||
}
|
||||
TrimThreadDevelopmentHistory(thread);
|
||||
}
|
||||
|
||||
foreach (CharacterConsequence consequence in Consequences.Values)
|
||||
{
|
||||
if (consequence != null && !retainedThreadIds.Contains(consequence.ThreadId))
|
||||
{
|
||||
consequence.ThreadId = "";
|
||||
}
|
||||
}
|
||||
|
||||
NarrativeEventStore.Compact(retainedDevelopmentIds, MaxRuntimeEvents);
|
||||
|
||||
var retainedCharacterIds = new HashSet<long>(protectedCharacters);
|
||||
foreach (NarrativeThread thread in Threads.Values)
|
||||
{
|
||||
if (thread != null && thread.ProtagonistId != 0)
|
||||
{
|
||||
retainedCharacterIds.Add(thread.ProtagonistId);
|
||||
}
|
||||
}
|
||||
foreach (CharacterConsequence consequence in Consequences.Values)
|
||||
{
|
||||
if (consequence == null) continue;
|
||||
if (consequence.CharacterId != 0) retainedCharacterIds.Add(consequence.CharacterId);
|
||||
if (consequence.OtherId != 0
|
||||
&& retainedCharacterIds.Count < MaxRuntimeCharacters)
|
||||
{
|
||||
retainedCharacterIds.Add(consequence.OtherId);
|
||||
}
|
||||
}
|
||||
|
||||
var rankedCharacters = new List<CharacterStoryState>(Characters.Values);
|
||||
rankedCharacters.Sort((a, b) =>
|
||||
{
|
||||
float aScore = EffectiveStoryPotential(a, now);
|
||||
float bScore = EffectiveStoryPotential(b, now);
|
||||
int score = bScore.CompareTo(aScore);
|
||||
if (score != 0) return score;
|
||||
return (b?.LastMeaningfulChangeAt ?? float.MinValue).CompareTo(
|
||||
a?.LastMeaningfulChangeAt ?? float.MinValue);
|
||||
});
|
||||
for (int i = 0;
|
||||
i < rankedCharacters.Count
|
||||
&& retainedCharacterIds.Count < MaxRuntimeCharacters;
|
||||
i++)
|
||||
{
|
||||
CharacterStoryState state = rankedCharacters[i];
|
||||
if (state != null && state.CharacterId != 0)
|
||||
{
|
||||
retainedCharacterIds.Add(state.CharacterId);
|
||||
}
|
||||
}
|
||||
RemoveUnretained(Characters, retainedCharacterIds);
|
||||
|
||||
RepairConsistency();
|
||||
Revision++;
|
||||
stopwatch.Stop();
|
||||
LastCompactionMs = (float)stopwatch.Elapsed.TotalMilliseconds;
|
||||
CompactionCount++;
|
||||
_nextRuntimeCompactionAt = now + RuntimeCompactionIntervalSeconds;
|
||||
|
||||
NeoModLoader.services.LogService.LogInfo(
|
||||
"[IdleSpectator][NARRATIVE] runtime compact "
|
||||
+ "events=" + beforeEvents + "->" + NarrativeEventStore.Count
|
||||
+ " developments=" + beforeDevelopments + "->" + Developments.Count
|
||||
+ " threads=" + beforeThreads + "->" + Threads.Count
|
||||
+ " consequences=" + beforeConsequences + "->" + Consequences.Count
|
||||
+ " characters=" + beforeCharacters + "->" + Characters.Count
|
||||
+ " ms=" + LastCompactionMs.ToString("0.0"));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static float ThreadRetentionScore(NarrativeThread thread, float now)
|
||||
{
|
||||
if (thread == null) return float.MinValue;
|
||||
float age = Mathf.Max(0f, now - thread.UpdatedAt);
|
||||
float freshness = Mathf.Max(0f, 60f - age / 10f);
|
||||
float open = thread.IsOpen ? 45f : 0f;
|
||||
float phase = thread.Phase == NarrativePhase.TurningPoint ? 24f
|
||||
: thread.Phase == NarrativePhase.Outcome ? 18f
|
||||
: thread.Phase == NarrativePhase.Consequence ? 14f : 0f;
|
||||
return freshness + open + phase + thread.Urgency * 3f
|
||||
+ thread.Momentum + thread.CoverageDebt;
|
||||
}
|
||||
|
||||
private static float DevelopmentRetentionScore(NarrativeDevelopment development)
|
||||
{
|
||||
if (development == null) return float.MinValue;
|
||||
float function;
|
||||
switch (development.Function)
|
||||
{
|
||||
case NarrativeFunction.TurningPoint: function = 60f; break;
|
||||
case NarrativeFunction.Resolution: function = 55f; break;
|
||||
case NarrativeFunction.Consequence: function = 50f; break;
|
||||
case NarrativeFunction.Escalation: function = 40f; break;
|
||||
case NarrativeFunction.Development: function = 30f; break;
|
||||
case NarrativeFunction.ThreadOpener: function = 25f; break;
|
||||
default: function = 0f; break;
|
||||
}
|
||||
return function + development.Magnitude * 0.2f
|
||||
+ development.Confidence * 5f;
|
||||
}
|
||||
|
||||
private static void AddExistingId<T>(
|
||||
string id,
|
||||
Dictionary<string, T> source,
|
||||
HashSet<string> retained)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && source.ContainsKey(id))
|
||||
{
|
||||
retained.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddExistingIds<T>(
|
||||
List<string> ids,
|
||||
Dictionary<string, T> source,
|
||||
HashSet<string> retained,
|
||||
int max = int.MaxValue)
|
||||
{
|
||||
if (ids == null) return;
|
||||
for (int i = 0; i < ids.Count && retained.Count < max; i++)
|
||||
{
|
||||
AddExistingId(ids[i], source, retained);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveUnretained<T>(
|
||||
Dictionary<string, T> source,
|
||||
HashSet<string> retained)
|
||||
{
|
||||
var remove = new List<string>();
|
||||
foreach (string id in source.Keys)
|
||||
{
|
||||
if (!retained.Contains(id)) remove.Add(id);
|
||||
}
|
||||
for (int i = 0; i < remove.Count; i++) source.Remove(remove[i]);
|
||||
}
|
||||
|
||||
private static void RemoveUnretained<T>(
|
||||
Dictionary<long, T> source,
|
||||
HashSet<long> retained)
|
||||
{
|
||||
var remove = new List<long>();
|
||||
foreach (long id in source.Keys)
|
||||
{
|
||||
if (!retained.Contains(id)) remove.Add(id);
|
||||
}
|
||||
for (int i = 0; i < remove.Count; i++) source.Remove(remove[i]);
|
||||
}
|
||||
|
||||
public static bool HarnessProbeCombatLifecycle(out string detail)
|
||||
{
|
||||
Clear();
|
||||
|
|
@ -660,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
|
||||
|
|
@ -687,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
|
||||
|
|
@ -697,6 +1143,159 @@ public static class NarrativeStoryStore
|
|||
return ok;
|
||||
}
|
||||
|
||||
public static bool HarnessProbeRuntimeCompaction(out string detail)
|
||||
{
|
||||
Clear();
|
||||
Record(new NarrativeDevelopment
|
||||
{
|
||||
Id = "probe:compact:ambient-child",
|
||||
Kind = NarrativeDevelopmentKind.ChildBorn,
|
||||
Function = NarrativeFunction.Consequence,
|
||||
SubjectId = 9000001,
|
||||
OtherId = 9000002,
|
||||
FamilyKey = "probe:ambient-family",
|
||||
EvidenceSource = "natural_soak",
|
||||
OccurredAt = 1f,
|
||||
Magnitude = 76f
|
||||
});
|
||||
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",
|
||||
Kind = NarrativeDevelopmentKind.ChildBorn,
|
||||
Function = NarrativeFunction.Consequence,
|
||||
SubjectId = 9000011,
|
||||
OtherId = 9000012,
|
||||
FamilyKey = "probe:authored-family",
|
||||
EvidenceSource = "harness_compaction",
|
||||
OccurredAt = 2f,
|
||||
Magnitude = 76f
|
||||
});
|
||||
bool authoredBirthProjected = ThreadCount == 2 && CharacterCount == 2;
|
||||
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;
|
||||
const int importedThreads = 900;
|
||||
for (int i = 0; i < importedDevelopments; i++)
|
||||
{
|
||||
string developmentId = "probe:compact:development:" + i;
|
||||
ImportDevelopment(new NarrativeDevelopment
|
||||
{
|
||||
Id = developmentId,
|
||||
Kind = NarrativeDevelopmentKind.HomeGained,
|
||||
Function = i == importedDevelopments - 1
|
||||
? NarrativeFunction.TurningPoint
|
||||
: NarrativeFunction.CharacterTexture,
|
||||
SubjectId = i + 1,
|
||||
OccurredAt = i + 1,
|
||||
Magnitude = i == importedDevelopments - 1 ? 100f : 5f
|
||||
});
|
||||
NarrativeEventStore.Import(new NarrativeEventRecord
|
||||
{
|
||||
Id = "event:" + developmentId,
|
||||
DevelopmentId = developmentId,
|
||||
SubjectId = i + 1,
|
||||
ObservedAt = i + 1,
|
||||
WorldTime = i + 1
|
||||
});
|
||||
ImportCharacter(new CharacterStoryState
|
||||
{
|
||||
CharacterId = i + 1,
|
||||
LastMeaningfulChangeAt = i + 1,
|
||||
StoryPotential = i % 20
|
||||
});
|
||||
if (i < importedThreads)
|
||||
{
|
||||
var thread = new NarrativeThread
|
||||
{
|
||||
Id = "probe:compact:thread:" + i,
|
||||
Kind = NarrativeThreadKind.Founding,
|
||||
ProtagonistId = i + 1,
|
||||
OpenedByDevelopmentId = developmentId,
|
||||
LatestMeaningfulChangeId = developmentId,
|
||||
Status = NarrativeThreadStatus.Resolved,
|
||||
Phase = NarrativePhase.Outcome,
|
||||
OpenedAt = i + 1,
|
||||
UpdatedAt = i + 1,
|
||||
Momentum = i / 100f
|
||||
};
|
||||
thread.DevelopmentIds.Add(developmentId);
|
||||
thread.AddCast(i + 1, "protagonist");
|
||||
ImportThread(thread);
|
||||
}
|
||||
}
|
||||
|
||||
bool compacted = CompactRuntimeGraph(force: true);
|
||||
bool graphClosed = true;
|
||||
foreach (NarrativeThread thread in Threads.Values)
|
||||
{
|
||||
if (thread == null
|
||||
|| Development(thread.LatestMeaningfulChangeId) == null)
|
||||
{
|
||||
graphClosed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
bool newestThreadKept = Thread("probe:compact:thread:899") != null;
|
||||
bool bounded = NarrativeEventStore.Count <= MaxRuntimeEvents
|
||||
&& Developments.Count <= MaxRuntimeDevelopments
|
||||
&& Threads.Count <= MaxRuntimeThreads
|
||||
&& Consequences.Count <= MaxRuntimeConsequences
|
||||
&& Characters.Count <= MaxRuntimeCharacters;
|
||||
bool ok = ambientGraphDeferred && authoredBirthProjected && authoredHomeProjected
|
||||
&& compacted && bounded && graphClosed && newestThreadKept;
|
||||
detail = "events=" + NarrativeEventStore.Count + "/" + MaxRuntimeEvents
|
||||
+ " developments=" + Developments.Count + "/" + MaxRuntimeDevelopments
|
||||
+ " threads=" + Threads.Count + "/" + MaxRuntimeThreads
|
||||
+ " consequences=" + Consequences.Count + "/" + MaxRuntimeConsequences
|
||||
+ " characters=" + Characters.Count + "/" + MaxRuntimeCharacters
|
||||
+ " newest=" + newestThreadKept
|
||||
+ " closed=" + graphClosed
|
||||
+ " deferred=" + ambientGraphDeferred
|
||||
+ " authoredBirth=" + authoredBirthProjected
|
||||
+ " authoredHome=" + authoredHomeProjected
|
||||
+ " ms=" + LastCompactionMs.ToString("0.0")
|
||||
+ " pass=" + ok;
|
||||
Clear();
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static void AddSchedulerCopy(List<NarrativeThread> into, NarrativeThread thread)
|
||||
{
|
||||
if (thread == null || into.Count >= SchedulerCopyLimit
|
||||
|
|
@ -852,6 +1451,7 @@ public static class NarrativeStoryStore
|
|||
{
|
||||
thread.DevelopmentIds.Add(terminal.Id);
|
||||
}
|
||||
TrimThreadDevelopmentHistory(thread);
|
||||
}
|
||||
|
||||
if (Characters.TryGetValue(thread.ProtagonistId, out CharacterStoryState state))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -841,7 +851,7 @@ public sealed class UnitDossier
|
|||
}
|
||||
|
||||
int start = i;
|
||||
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_' || text[i] == '-'))
|
||||
while (i < text.Length && IsNameTokenChar(text[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
|
@ -1016,9 +1026,9 @@ public sealed class UnitDossier
|
|||
return false;
|
||||
}
|
||||
|
||||
bool leftOk = idx == 0 || !char.IsLetterOrDigit(reason[idx - 1]);
|
||||
bool leftOk = idx == 0 || !IsNameTokenChar(reason[idx - 1]);
|
||||
int end = idx + name.Length;
|
||||
bool rightOk = end >= reason.Length || !char.IsLetterOrDigit(reason[end]);
|
||||
bool rightOk = end >= reason.Length || !IsNameTokenChar(reason[end]);
|
||||
if (leftOk && rightOk)
|
||||
{
|
||||
return true;
|
||||
|
|
@ -1030,6 +1040,37 @@ public sealed class UnitDossier
|
|||
return false;
|
||||
}
|
||||
|
||||
private static bool IsNameTokenChar(char c)
|
||||
{
|
||||
return char.IsLetterOrDigit(c)
|
||||
|| c == '_'
|
||||
|| c == '-'
|
||||
|| c == '\''
|
||||
|| c == '\u2019';
|
||||
}
|
||||
|
||||
public static bool HarnessProbeNameBoundaries(out string detail)
|
||||
{
|
||||
bool apostropheSuffixRejected =
|
||||
!ContainsWholeName("O'Ehel decides to try to reproduce", "Ehel");
|
||||
bool curlySuffixRejected =
|
||||
!ContainsWholeName("O\u2019Ehel decides to try to reproduce", "Ehel");
|
||||
bool hyphenSuffixRejected =
|
||||
!ContainsWholeName("Ana-Maria gains a child", "Maria");
|
||||
bool separateNameAccepted =
|
||||
ContainsWholeName("Ulalfa gains a child, Asodahl", "Asodahl");
|
||||
bool ok = apostropheSuffixRejected
|
||||
&& curlySuffixRejected
|
||||
&& hyphenSuffixRejected
|
||||
&& separateNameAccepted;
|
||||
detail = "apostrophe=" + apostropheSuffixRejected
|
||||
+ " curly=" + curlySuffixRejected
|
||||
+ " hyphen=" + hyphenSuffixRejected
|
||||
+ " separate=" + separateNameAccepted
|
||||
+ " pass=" + ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static string EventLabelBeat(string label, UnitDossier d, bool recordDrops = true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(label))
|
||||
|
|
|
|||
|
|
@ -120,9 +120,32 @@ public static class WatchCaption
|
|||
private static bool _dossierRowsNeedRestore;
|
||||
private static float _nextPortraitAt;
|
||||
private static long _portraitActorId;
|
||||
private const float PortraitRefreshSeconds = 1f;
|
||||
private static string _statusBanner = "";
|
||||
private static float _statusBannerUntil;
|
||||
private static string _lastLoggedCaption = "";
|
||||
// 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
|
||||
{
|
||||
|
|
@ -479,6 +502,7 @@ public static class WatchCaption
|
|||
|
||||
public static void Clear()
|
||||
{
|
||||
InvalidateOwnedReasonCache();
|
||||
_current = null;
|
||||
_boundActor = null;
|
||||
LastHeadline = "";
|
||||
|
|
@ -594,6 +618,7 @@ public static class WatchCaption
|
|||
LastCaptionText = LastHeadline;
|
||||
LastHistoryPreview = "";
|
||||
LastHistoryJoined = "";
|
||||
InvalidateOwnedReasonCache();
|
||||
_current = null;
|
||||
_boundActor = null;
|
||||
EnsureBuilt();
|
||||
|
|
@ -617,6 +642,7 @@ public static class WatchCaption
|
|||
|
||||
public static void SetFromActor(Actor actor, string label = null)
|
||||
{
|
||||
InvalidateOwnedReasonCache();
|
||||
if (!_pinnedWhilePaused)
|
||||
{
|
||||
ClearStatusBanner();
|
||||
|
|
@ -645,6 +671,7 @@ public static class WatchCaption
|
|||
return;
|
||||
}
|
||||
|
||||
InvalidateOwnedReasonCache();
|
||||
if (!_pinnedWhilePaused)
|
||||
{
|
||||
ClearStatusBanner();
|
||||
|
|
@ -679,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)
|
||||
{
|
||||
|
|
@ -757,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)
|
||||
{
|
||||
|
|
@ -768,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>
|
||||
|
|
@ -851,6 +929,11 @@ public static class WatchCaption
|
|||
|
||||
public static void ClearStatusBanner()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_statusBanner) || _statusBannerUntil != 0f)
|
||||
{
|
||||
InvalidateOwnedReasonCache();
|
||||
}
|
||||
|
||||
_statusBanner = "";
|
||||
_statusBannerUntil = 0f;
|
||||
if (_reasonText != null)
|
||||
|
|
@ -921,7 +1004,8 @@ public static class WatchCaption
|
|||
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
float now = Time.unscaledTime;
|
||||
// Same unit: tile refresh every frame hitchs; keep the live pose at ~5 Hz.
|
||||
// The vanilla tile refresh can trigger an expensive UI/layout pass. A portrait is
|
||||
// supporting context, not animation-critical, so refresh it at 1 Hz while held.
|
||||
// Focus change always applies immediately.
|
||||
if (id == _portraitActorId && now < _nextPortraitAt)
|
||||
{
|
||||
|
|
@ -929,7 +1013,7 @@ public static class WatchCaption
|
|||
}
|
||||
|
||||
_portraitActorId = id;
|
||||
_nextPortraitAt = now + 0.2f;
|
||||
_nextPortraitAt = now + PortraitRefreshSeconds;
|
||||
DossierAvatar.Show(actor);
|
||||
BringHeaderFront();
|
||||
}
|
||||
|
|
@ -950,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))
|
||||
|
|
@ -960,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);
|
||||
}
|
||||
|
|
@ -974,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)
|
||||
{
|
||||
|
|
@ -983,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
|
||||
|
|
@ -1013,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