feat(event): enhance event handling and narrative presentation probes

- Introduce new probes for framing approval and family coalescing
- Implement exact presentation replay checks to prevent duplicate events
- Refactor InterestVariety to manage exact replay cooldowns effectively
- Update EventPresentability to improve candidate approval logic
- Enhance InterestFeeds to streamline event registration and labeling
This commit is contained in:
DazedAnon 2026-07-23 19:43:29 -05:00
parent 54476fb7a1
commit 2293274d79
19 changed files with 1418 additions and 97 deletions

View file

@ -2892,6 +2892,14 @@ public static class AgentHarness
break; 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": case "interest_story_clear":
StoryPlanner.Clear(); StoryPlanner.Clear();
_cmdOk++; _cmdOk++;
@ -2944,6 +2952,10 @@ public static class AgentHarness
bool spineOk = WatchCaption.HarnessProbeStorySpineRelevance(out string spine); bool spineOk = WatchCaption.HarnessProbeStorySpineRelevance(out string spine);
bool combatOk = StickyScoreboard.HarnessProbeReasonStability(out string combat); bool combatOk = StickyScoreboard.HarnessProbeReasonStability(out string combat);
bool noveltyOk = InterestVariety.HarnessProbeRoutineReplay(out string novelty); bool noveltyOk = InterestVariety.HarnessProbeRoutineReplay(out string novelty);
bool familyCoalescingOk =
InterestVariety.HarnessProbeFamilyCoalescing(out string familyCoalescing);
bool nameBoundaryOk =
UnitDossier.HarnessProbeNameBoundaries(out string nameBoundary);
bool participantOk = bool participantOk =
NarrativePresentationFactory.HarnessProbeNamedParticipantFallback( NarrativePresentationFactory.HarnessProbeNamedParticipantFallback(
out string participant); out string participant);
@ -2958,6 +2970,12 @@ public static class AgentHarness
NarrativeStoryStore.HarnessProbeCombatLifecycle(out string lifecycle); NarrativeStoryStore.HarnessProbeCombatLifecycle(out string lifecycle);
bool schedulerBoundsOk = bool schedulerBoundsOk =
NarrativeStoryStore.HarnessProbeSchedulerBounds(out string schedulerBounds); 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 = bool exposureOk =
NarrativeExposureLedger.HarnessProbeBalance(out string exposure); NarrativeExposureLedger.HarnessProbeBalance(out string exposure);
bool interruptOk = bool interruptOk =
@ -2966,8 +2984,12 @@ public static class AgentHarness
NarrativePersistence.HarnessProbeGraphCompaction(out string graph); NarrativePersistence.HarnessProbeGraphCompaction(out string graph);
bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence); bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence);
bool ok = contextOk && spineOk && combatOk && noveltyOk bool ok = contextOk && spineOk && combatOk && noveltyOk
&& familyCoalescingOk && nameBoundaryOk
&& participantOk && loverOk && identityOk && episodeOk && participantOk && loverOk && identityOk && episodeOk
&& proseOk && lifecycleOk && schedulerBoundsOk && proseOk && lifecycleOk && schedulerBoundsOk
&& runtimeCompactionOk
&& exactPresentationOk
&& framingApprovalOk
&& exposureOk && interruptOk && exposureOk && interruptOk
&& graphOk && persistenceOk; && graphOk && persistenceOk;
if (ok) _cmdOk++; else _cmdFail++; if (ok) _cmdOk++; else _cmdFail++;
@ -2976,10 +2998,15 @@ public static class AgentHarness
ok, ok,
detail: "context={" + context + "} spine={" + spine detail: "context={" + context + "} spine={" + spine
+ "} combat={" + combat + "} novelty={" + novelty + "} combat={" + combat + "} novelty={" + novelty
+ "} familyCoalescing={" + familyCoalescing
+ "} nameBoundary={" + nameBoundary
+ "} participant={" + participant + "} lover={" + lover + "} participant={" + participant + "} lover={" + lover
+ "} identity={" + identity + "} episode={" + episode + "} identity={" + identity + "} episode={" + episode
+ "} prose={" + prose + "} lifecycle={" + lifecycle + "} prose={" + prose + "} lifecycle={" + lifecycle
+ "} schedulerBounds={" + schedulerBounds + "} schedulerBounds={" + schedulerBounds
+ "} runtimeCompaction={" + runtimeCompaction
+ "} exactPresentation={" + exactPresentation
+ "} framingApproval={" + framingApproval
+ "} exposure={" + exposure + "} interrupt={" + interrupt + "} exposure={" + exposure + "} interrupt={" + interrupt
+ "} graph={" + graph + "} graph={" + graph
+ "} persistence={" + persistence + "}"); + "} persistence={" + persistence + "}");
@ -4446,8 +4473,13 @@ public static class AgentHarness
detail: "schedulerMs=" + StoryScheduler.LastTickMs.ToString("0.00") detail: "schedulerMs=" + StoryScheduler.LastTickMs.ToString("0.00")
+ " candidates=" + StoryScheduler.LastCandidateCount + " candidates=" + StoryScheduler.LastCandidateCount
+ "/" + NarrativeStoryStore.SchedulerCopyLimit + "/" + NarrativeStoryStore.SchedulerCopyLimit
+ " graphEvents=" + NarrativeEventStore.Count
+ " graphThreads=" + NarrativeStoryStore.ThreadCount + " 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; break;
} }
@ -7214,7 +7246,7 @@ public static class AgentHarness
} }
/// <summary> /// <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. /// value = status id (default cursed). Seeds live cursed carriers so maintain recounts stick.
/// </summary> /// </summary>
private static void DoInterestOutbreakSession(HarnessCommand cmd) private static void DoInterestOutbreakSession(HarnessCommand cmd)

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using NeoModLoader.services; using NeoModLoader.services;
using UnityEngine; using UnityEngine;
@ -9,6 +10,9 @@ public static class CameraDirector
public static string LastWatchLabel { get; private set; } = ""; public static string LastWatchLabel { get; private set; } = "";
public static string LastWatchAssetId { get; private set; } = ""; public static string LastWatchAssetId { get; private set; } = "";
public static string LastFormattedWatchTip { 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) public static void FocusUnit(Actor actor, bool updateCaption = true)
{ {
@ -34,6 +38,16 @@ public static class CameraDirector
return; 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); Actor follow = ResolveFollowUnit(interest);
if (follow != null && follow.isAlive()) 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. // Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only.
if (tipChanged && !framingOnly) if (tipChanged && !framingOnly)
{ {
NotePresentedLabel(label, Time.unscaledTime);
LogService.LogInfo($"[IdleSpectator] {tip}"); 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) private static Actor ResolveFollowUnit(InterestEvent interest)
{ {
if (interest == null) if (interest == null)

View file

@ -254,7 +254,13 @@ public static class EventLivePipelinesHarness
} }
InterestDropLog.Clear(); 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 try
{ {
if (!TryPostWorldLog(id, unit)) if (!TryPostWorldLog(id, unit))
@ -264,10 +270,14 @@ public static class EventLivePipelinesHarness
continue; continue;
} }
if (InterestRegistry.CountKeysContaining("worldlog:" + id) > 0) if (InterestRegistry.CountKeysContaining(expectedKey) > 0)
{ {
EventLiveCoverageLedger.Claim( 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)) else if (DropMentions("createsInterest=false", id))
{ {

View file

@ -94,8 +94,8 @@ public static class EventPresentability
/// <summary> /// <summary>
/// Stamp the exact identity that passed the intake gate. Repeated scheduler/director /// Stamp the exact identity that passed the intake gate. Repeated scheduler/director
/// checks can trust this fingerprint without rescanning the world; any later mutation /// checks can trust this fingerprint without rescanning the world. Ownership changes
/// naturally invalidates it. /// invalidate it; explicitly recognized live framing updates retain approval.
/// </summary> /// </summary>
public static void MarkApproved(InterestCandidate scene) public static void MarkApproved(InterestCandidate scene)
{ {
@ -111,16 +111,71 @@ public static class EventPresentability
scene.HasApprovedPresentation = true; scene.HasApprovedPresentation = true;
} }
public static bool IsApproved(InterestCandidate scene) => public static bool IsApproved(InterestCandidate scene)
scene != null {
&& scene.HasApprovedPresentation if (scene == null
&& scene.ApprovedPresentationSubjectId == scene.SubjectId || !scene.HasApprovedPresentation
&& scene.ApprovedPresentationRelatedId == scene.RelatedId || scene.ApprovedPresentationSubjectId != scene.SubjectId
&& scene.ApprovedPresentationLeadKind == scene.LeadKind || scene.ApprovedPresentationRelatedId != scene.RelatedId
&& string.Equals( || scene.ApprovedPresentationLeadKind != scene.LeadKind)
scene.ApprovedPresentationLabel, {
scene.Label ?? "", return false;
StringComparison.Ordinal); }
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() private static void TrimCache()
{ {

View file

@ -61,6 +61,25 @@ public static class EventReason
return SameCombatTheaterCamps(prevKey, nextKey); 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) private static bool SameCombatTheaterCamps(string prevKey, string nextKey)
{ {
if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b)) if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b))
@ -140,7 +159,10 @@ public static class EventReason
return false; 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); int vs = rest.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase);
if (vs < 0) if (vs < 0)
{ {
@ -714,19 +736,12 @@ public static class EventReason
return an + " changes status"; return an + " changes status";
} }
// Prose often already includes a verb phrase ("is burning"). return SubjectLedClause(an, p);
if (p.StartsWith("is ", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("are ", StringComparison.OrdinalIgnoreCase))
{
return an + " " + p;
}
return an + " · " + p;
} }
/// <summary> /// <summary>
/// Sticky status outbreak orange reason: <c>Outbreak - Cursed (4)</c>. /// Stable sticky status outbreak reason, e.g. <c>Outbreak - Cursed</c>.
/// Collective status label + carrier count; never unit proper names. /// Counts remain in the typed ensemble but do not churn the orange story sentence.
/// </summary> /// </summary>
public static string StatusOutbreak(LiveEnsemble ensemble) public static string StatusOutbreak(LiveEnsemble ensemble)
{ {
@ -741,12 +756,12 @@ public static class EventReason
return ""; return "";
} }
string label = ensemble.SideA.Display ?? "";
if (string.IsNullOrEmpty(label) || LiveEnsemble.IsStatusOutbreakSideKey(label))
{
string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key) string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)
? ensemble.SideA.Key.Substring("status:".Length) ? ensemble.SideA.Key.Substring("status:".Length)
: ""; : "";
string label = ensemble.SideA.Display ?? "";
if (string.IsNullOrEmpty(label) || LiveEnsemble.IsStatusOutbreakSideKey(label))
{
label = LiveEnsemble.StatusDisplayName(statusId); label = LiveEnsemble.StatusDisplayName(statusId);
} }
@ -767,9 +782,29 @@ public static class EventReason
label = "Afflicted"; label = "Afflicted";
} }
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
int n = Math.Max(0, ensemble.SideA.Count); 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) 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); : "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) private static string NormalizeBuildingDisplay(string buildingId, string display)
{ {
string raw = (buildingId ?? "").Trim().ToLowerInvariant(); string raw = (buildingId ?? "").Trim().ToLowerInvariant();
@ -1094,12 +1210,28 @@ public static class EventReason
for (int i = 0; i < cultureSuffixes.Length; i++) for (int i = 0; i < cultureSuffixes.Length; i++)
{ {
string suffix = "_" + cultureSuffixes[i]; string suffix = "_" + cultureSuffixes[i];
if (!raw.EndsWith(suffix, StringComparison.Ordinal)) int suffixAt = raw.LastIndexOf(suffix, StringComparison.Ordinal);
if (suffixAt < 0)
{ {
continue; 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); string fallback = HumanizeId(raw);
if (string.IsNullOrEmpty(value) if (string.IsNullOrEmpty(value)
|| value.Equals(fallback, StringComparison.OrdinalIgnoreCase)) || value.Equals(fallback, StringComparison.OrdinalIgnoreCase))
@ -1133,12 +1265,35 @@ public static class EventReason
string building = BuildingComplete(null, "tent_human", spectacle: false); string building = BuildingComplete(null, "tent_human", spectacle: false);
bool buildingClean = building == "Building completed: Tent" bool buildingClean = building == "Building completed: Tent"
&& building.IndexOf("Human", StringComparison.OrdinalIgnoreCase) < 0; && 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 species = LiveEnsemble.SpeciesDisplay("slava");
string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay("Slava"); string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay("Slava");
bool collectiveClean = string.Equals( bool collectiveClean = string.Equals(
species, kingdomAsSpecies, StringComparison.OrdinalIgnoreCase); species, kingdomAsSpecies, StringComparison.OrdinalIgnoreCase);
bool ok = buildingClean && collectiveClean; bool ok = buildingClean && variantBuildingClean && killClean && friendClean
detail = "building='" + building + "' species='" + species && 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; + "' kingdomSpecies='" + kingdomAsSpecies + "' pass=" + ok;
return ok; return ok;
} }

View file

@ -160,24 +160,40 @@ public static class InterestFeeds
string assetId = message.asset_id ?? entry.Id ?? "worldlog"; string assetId = message.asset_id ?? entry.Id ?? "worldlog";
string kingdom = message.special1 ?? ""; 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); float boost = PeekCivicBoost(kingdom, assetId);
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category; string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
string label = entry.MakeLabel(message); string label = entry.MakeLabel(message);
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos)) if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
{ {
if (earthquake)
{
label = EventReason.Earthquake(follow);
}
EventFeedUtil.Register( EventFeedUtil.Register(
key, key,
"worldlog", earthquake ? "earthquake" : "worldlog",
category, category,
label, label,
assetId, earthquake ? "earthquake" : assetId,
evtSeed + boost, evtSeed + boost,
pos, pos,
follow, follow,
minWatch: entry.MinWatch, minWatch: entry.MinWatch,
maxWatch: entry.MaxWatch); maxWatch: earthquake ? 45f : entry.MaxWatch,
completion: earthquake
? InterestCompletionKind.EarthquakeActive
: InterestCompletionKind.FixedDwell);
return; return;
} }
@ -186,16 +202,19 @@ public static class InterestFeeds
{ {
EventFeedUtil.Register( EventFeedUtil.Register(
key, key,
"worldlog", earthquake ? "earthquake" : "worldlog",
category, category,
label, earthquake ? "Earthquake shakes the land" : label,
assetId, earthquake ? "earthquake" : assetId,
evtSeed + boost, evtSeed + boost,
position, position,
follow: null, follow: null,
locationOnly: true, locationOnly: true,
minWatch: entry.MinWatch, minWatch: entry.MinWatch,
maxWatch: entry.MaxWatch); maxWatch: earthquake ? 45f : entry.MaxWatch,
completion: earthquake
? InterestCompletionKind.EarthquakeActive
: InterestCompletionKind.FixedDwell);
} }
} }
@ -657,6 +676,19 @@ public static class InterestFeeds
long sid = SafeId(subject); long sid = SafeId(subject);
long rid = SafeId(related); 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. // Shared key space with happiness canonical merges.
string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid; string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid;
var candidate = new InterestCandidate var candidate = new InterestCandidate
@ -672,7 +704,7 @@ public static class InterestFeeds
RelatedUnit = related, RelatedUnit = related,
SubjectId = sid, SubjectId = sid,
RelatedId = rid, RelatedId = rid,
Label = label ?? milestoneKey, Label = cameraLabel,
// Milestone id owns AssetId so kill/lover/friend crumbs cool as a class // 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). // (species id used to land every hunter tip on arc:asset:elf).
AssetId = milestoneKey, AssetId = milestoneKey,
@ -831,7 +863,10 @@ public static class InterestFeeds
} }
else if (!string.IsNullOrEmpty(occ.PlainClause)) 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 else
{ {

View file

@ -62,7 +62,16 @@ public static class StatusOutbreakFeed
return; 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; private static float _lastTickAt = -999f;
@ -498,5 +507,8 @@ public static class StatusOutbreakFeed
registered.Label = EventReason.StatusOutbreak(ensemble); registered.Label = EventReason.StatusOutbreak(ensemble);
registered.ParticipantCount = ensemble.ParticipantCount; registered.ParticipantCount = ensemble.ParticipantCount;
InterestScoring.ScoreCheap(registered); InterestScoring.ScoreCheap(registered);
// ApplySticky runs after registry intake. Its generated crowd framing is
// authoritative and ownership-preserving, so retain that approval.
EventPresentability.MarkApproved(registered);
} }
} }

View file

@ -167,6 +167,13 @@ public static class IdleHitchProbe
+ $"{StoryScheduler.LastThreadScoreMs:0.0}/" + $"{StoryScheduler.LastThreadScoreMs:0.0}/"
+ $"{StoryScheduler.LastPendingCopyMs:0.0}/" + $"{StoryScheduler.LastPendingCopyMs:0.0}/"
+ $"{StoryScheduler.LastProposalMs:0.0} " + $"{StoryScheduler.LastProposalMs:0.0} "
+ $"selectorParts={EpisodeShotSelector.LastReplayFilterMs:0.0}/"
+ $"{EpisodeShotSelector.LastPresentabilityMs:0.0}/"
+ $"{EpisodeShotSelector.LastClassificationMs:0.0}/"
+ $"{EpisodeShotSelector.LastScoringMs:0.0} "
+ $"selectorPresent={EpisodeShotSelector.LastPresentabilityChecks} "
+ $"selectorSlow={EpisodeShotSelector.LastSlowCandidateMs:0.0}:"
+ $"{EpisodeShotSelector.LastSlowCandidateKey} "
+ $"feedParts={InterestFeeds.LastHappinessMs:0.0}/" + $"feedParts={InterestFeeds.LastHappinessMs:0.0}/"
+ $"{InterestFeeds.LastScannerMs:0.0}[{InterestFeeds.LastBattleScanMs:0.0}," + $"{InterestFeeds.LastScannerMs:0.0}[{InterestFeeds.LastBattleScanMs:0.0},"
+ $"{InterestFeeds.LastWarFeedMs:0.0},{InterestFeeds.LastPlotFeedMs:0.0}," + $"{InterestFeeds.LastWarFeedMs:0.0},{InterestFeeds.LastPlotFeedMs:0.0},"

View file

@ -116,6 +116,25 @@ public sealed class InterestCandidate
public string ScoreDetail = ""; public string ScoreDetail = "";
public readonly List<long> ParticipantIds = new List<long>(4); 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. // Compat forwarders - existing combat maintain/harness code.
public int CombatPeakParticipants public int CombatPeakParticipants
{ {

View file

@ -1135,7 +1135,7 @@ public static partial class InterestDirector
_current.RelatedId = EventFeedUtil.SafeId(held.Related); _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; int carriers = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
if (carriers < 2) if (carriers < 2)
{ {

View file

@ -27,6 +27,11 @@ public static partial class InterestDirector
InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key); InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key);
return false; return false;
} }
if (candidate != _current && InterestVariety.IsExactReplayCooled(candidate))
{
InterestDropLog.Record("exact_replay", candidate.Label ?? candidate.Key);
return false;
}
if (candidate != _current if (candidate != _current
&& !StoryScheduler.MayUseCombatShot(candidate)) && !StoryScheduler.MayUseCombatShot(candidate))

View file

@ -17,6 +17,7 @@ public static class InterestVariety
public static float FixedDwellBeatCooldownSeconds => public static float FixedDwellBeatCooldownSeconds =>
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds); Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f); public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f);
public static float ExactReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 90f);
public static float RoutineReplayCooldownSeconds => public static float RoutineReplayCooldownSeconds =>
Mathf.Max(FixedDwellBeatCooldownSeconds, 120f); Mathf.Max(FixedDwellBeatCooldownSeconds, 120f);
@ -52,6 +53,7 @@ public static class InterestVariety
LastPickHadBothPools = false; LastPickHadBothPools = false;
_lastArcKey = ""; _lastArcKey = "";
_arcStreak = 0; _arcStreak = 0;
CameraDirector.ClearReplayLedger();
} }
/// <summary>Harness: push an arc into the rarity window without a full selection.</summary> /// <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); 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 chapters cool ledger heat so the same villagers do not monopolize.
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed). // 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; 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) public static bool HarnessProbeEditorialReplay(out string detail)
{ {
Clear(); Clear();
@ -224,7 +249,7 @@ public static class InterestVariety
var replay = new InterestCandidate var replay = new InterestCandidate
{ {
Key = "combat:pair:1:2:refresh", Key = "combat:pair:1:2:refresh",
Label = "Duel - Alpha vs Beta", Label = "Mass - Beta (3) vs Alpha (5)",
Completion = InterestCompletionKind.CombatActive Completion = InterestCompletionKind.CombatActive
}; };
var different = new InterestCandidate var different = new InterestCandidate
@ -241,12 +266,39 @@ public static class InterestVariety
StatusId = "sleeping" StatusId = "sleeping"
}; };
bool sameCooled = IsEditorialReplayCooled(replay); bool sameCooled = IsEditorialReplayCooled(replay);
bool structuralExactCooled = IsExactReplayCooled(replay);
bool differentOpen = !IsEditorialReplayCooled(different); bool differentOpen = !IsEditorialReplayCooled(different);
bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture); 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 detail = "sameCooled=" + sameCooled
+ " structuralExact=" + structuralExactCooled
+ " differentOpen=" + differentOpen + " differentOpen=" + differentOpen
+ " textureUsesBeatPolicy=" + textureUsesBeatPolicy; + " textureUsesBeatPolicy=" + textureUsesBeatPolicy
+ " exactLife=" + exactLifeCooled
+ " progressedLife=" + progressedLifeOpen;
Clear(); Clear();
return ok; return ok;
} }
@ -319,6 +371,43 @@ public static class InterestVariety
return ok; 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> /// <summary>
/// Score to subtract when this candidate matches the last shown variety arc. /// 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. /// 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; continue;
} }
if (IsExactReplayCooled(c, now))
{
InterestDropLog.Record("exact_replay", c.Label ?? c.Key);
continue;
}
if (IsRoutineReplayCooled(c, now)) if (IsRoutineReplayCooled(c, now))
{ {
InterestDropLog.Record("routine_replay", c.Label ?? c.Key); InterestDropLog.Record("routine_replay", c.Label ?? c.Key);
@ -1008,6 +1103,14 @@ public static class InterestVariety
{ {
return ""; return "";
} }
EnsureReplayKeys(c);
return c.CachedEditorialReplayKey;
}
private static string BuildEditorialReplayKey(
InterestCandidate c,
string normalizedLabel)
{
bool stickyCard = c.Completion == InterestCompletionKind.CombatActive bool stickyCard = c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront || c.Completion == InterestCompletionKind.WarFront
@ -1021,22 +1124,37 @@ public static class InterestVariety
return ""; return "";
} }
string label = (c.Label ?? "").Trim().ToLowerInvariant(); if (!string.IsNullOrEmpty(normalizedLabel))
if (!string.IsNullOrEmpty(label))
{ {
return "replay:label:" + label; return "replay:label:" + normalizedLabel;
} }
string key = (c.Key ?? "").Trim().ToLowerInvariant(); string key = (c.Key ?? "").Trim().ToLowerInvariant();
return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key; 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) private static string RoutineReplayKey(InterestCandidate c)
{ {
if (c == null || c.SubjectId == 0) if (c == null || c.SubjectId == 0)
{ {
return ""; return "";
} }
EnsureReplayKeys(c);
return c.CachedRoutineReplayKey;
}
private static string BuildRoutineReplayKey(InterestCandidate c)
{
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c); NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
if (function != NarrativeFunction.CharacterTexture if (function != NarrativeFunction.CharacterTexture
@ -1057,6 +1175,53 @@ public static class InterestVariety
: "routine:" + c.SubjectId + ":" + family; : "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> /// <summary>
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class. /// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
/// </summary> /// </summary>
@ -1839,6 +2004,15 @@ public static class InterestVariety
yield return "beat:life:love:world"; 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)) if (EventCatalog.Decision.IsLifeIntentDecision(asset))
{ {
yield return "beat:asset:" + asset + ":world"; yield return "beat:asset:" + asset + ":world";

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine; using UnityEngine;
namespace IdleSpectator; 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> /// <summary>Selects the best presentable shot that can explain its place in the active episode.</summary>
public static class EpisodeShotSelector 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( public static EpisodeShotProposal Pick(
IList<InterestCandidate> candidates, IList<InterestCandidate> candidates,
EpisodePlan episode, EpisodePlan episode,
NarrativeThread thread, NarrativeThread thread,
bool requirePresentable = true) bool requirePresentable = true)
{ {
ResetTiming();
if (candidates == null || candidates.Count == 0 || episode == null) return null; if (candidates == null || candidates.Count == 0 || episode == null) return null;
EpisodeShotProposal bestRelated = null; bool trace = IdleHitchProbe.Enabled;
EpisodeShotProposal bestInterrupt = null; 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++) for (int i = 0; i < candidates.Count; i++)
{ {
InterestCandidate c = candidates[i]; InterestCandidate c = candidates[i];
bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase); bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase);
if (c == null || c.Selected if (c == null || c.Selected) continue;
|| InterestVariety.IsEditorialReplayCooled(c)
|| InterestVariety.IsRoutineReplayCooled(c) long stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
|| !StoryScheduler.MayUseCombatShot(episode, c) bool replayCooled =
|| (requirePresentable && !harness && !EventPresentability.WouldShow(c))) continue; 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); 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); NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
EpisodeShotRole role = RoleFor(c, function, kind, episode); EpisodeShotRole role = RoleFor(c, function, kind, episode);
string semanticKey = SemanticKey(c, role);
bool related = kind == NarrativeInterruptClass.RelatedEscalation; 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 if (related
&& !AdvancesEpisode(episode, role) && !advances
&& episode.RecentSemanticKeys.Contains(semanticKey)) && episode.RecentSemanticKeys.Contains(semanticKey))
{ {
continue; continue;
@ -53,36 +112,91 @@ public static class EpisodeShotSelector
continue; continue;
} }
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
float score = EditorialScore(c, episode, kind, function, role); float score = EditorialScore(c, episode, kind, function, role);
var proposal = new EpisodeShotProposal if (trace)
{ {
Candidate = c, LastScoringMs += ElapsedMs(stageStarted);
InterruptClass = kind, }
EditorialScore = score,
Role = role,
SemanticKey = semanticKey,
Reason = Reason(kind) + ":" + role
};
if (kind == NarrativeInterruptClass.RelatedEscalation) 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) 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 // 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. // only when no related shot is ready, or when its editorial advantage is decisive.
if (bestRelated != null if (bestRelatedCandidate != null
&& (bestInterrupt == null || bestInterrupt.EditorialScore < bestRelated.EditorialScore + 25f)) && (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( private static float EditorialScore(

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator; namespace IdleSpectator;
@ -51,4 +52,53 @@ public static class NarrativeEventStore
Events[record.Id] = record; Events[record.Id] = record;
return true; 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);
}
} }

View file

@ -90,12 +90,13 @@ public static class NarrativeExposureLedger
} }
/// <summary> /// <summary>
/// Two routine combat cuts may establish a conflict; another story family must then /// Two combat cuts may establish and escalate a conflict; another story family must
/// receive space. World-critical combat bypasses this global exposure cap. /// then receive space. Criticality affects which combat cut wins, not whether combat
/// may exceed the global story-first exposure budget.
/// </summary> /// </summary>
public static bool MayPresent(InterestCandidate candidate) public static bool MayPresent(InterestCandidate candidate)
{ {
if (!IsCombat(candidate) || candidate.EventStrength >= 95f) if (!IsCombat(candidate))
{ {
return true; return true;
} }
@ -150,18 +151,19 @@ public static class NarrativeExposureLedger
NotePresented(combat); NotePresented(combat);
NotePresented(combat); NotePresented(combat);
bool routineBlocked = !MayPresent(combat); bool routineBlocked = !MayPresent(combat);
bool criticalAllowed = MayPresent(criticalCombat); bool criticalBounded = !MayPresent(criticalCombat);
for (int i = 0; i < 5; i++) NotePresented(family); for (int i = 0; i < 5; i++) NotePresented(family);
bool routineReopened = MayPresent(combat); bool routineReopened = MayPresent(combat);
bool ok = underCovered > prolific bool ok = underCovered > prolific
&& repetition > 0f && repetition > 0f
&& diverse == 0f && diverse == 0f
&& routineBlocked && routineBlocked
&& criticalAllowed && criticalBounded
&& routineReopened; && routineReopened;
detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0") detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0")
+ " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0") + " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0")
+ " combat=" + routineBlocked + "/" + criticalAllowed + "/" + routineReopened + " combat=" + routineBlocked + "/" + criticalBounded
+ "/" + routineReopened
+ " pass=" + ok; + " pass=" + ok;
Clear(); Clear();
return ok; return ok;

View file

@ -137,6 +137,8 @@ public sealed class NarrativeCastRole
/// <summary>Durable unresolved question centered on one life.</summary> /// <summary>Durable unresolved question centered on one life.</summary>
public sealed class NarrativeThread public sealed class NarrativeThread
{ {
private const int MaxRetainedCast = 16;
public string Id = ""; public string Id = "";
public NarrativeThreadKind Kind; public NarrativeThreadKind Kind;
public long ProtagonistId; public long ProtagonistId;
@ -177,6 +179,18 @@ public sealed class NarrativeThread
{ {
if (id == 0 || HasCast(id)) return; if (id == 0 || HasCast(id)) return;
Cast.Add(new NarrativeCastRole { CharacterId = id, Role = role ?? "" }); 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);
}
} }
} }

View file

@ -11,6 +11,13 @@ public static class NarrativeStoryStore
private const int ConsequencesPerCharacter = 24; private const int ConsequencesPerCharacter = 24;
private const int OpenThreadsPerCharacter = 16; private const int OpenThreadsPerCharacter = 16;
private const int ResolvedThreadsPerCharacter = 24; 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 SchedulerCandidateLimit = 96;
internal const int SchedulerCopyLimit = 128; internal const int SchedulerCopyLimit = 128;
private static readonly Dictionary<string, NarrativeDevelopment> Developments = private static readonly Dictionary<string, NarrativeDevelopment> Developments =
@ -33,11 +40,15 @@ public static class NarrativeStoryStore
private static readonly Dictionary<string, HashSet<long>> ConflictCastByThread = private static readonly Dictionary<string, HashSet<long>> ConflictCastByThread =
new Dictionary<string, HashSet<long>>(StringComparer.Ordinal); new Dictionary<string, HashSet<long>>(StringComparer.Ordinal);
private static readonly List<string> IdScratch = new List<string>(SchedulerCandidateLimit); private static readonly List<string> IdScratch = new List<string>(SchedulerCandidateLimit);
private static float _nextRuntimeCompactionAt;
public static int Revision { get; private set; } public static int Revision { get; private set; }
public static int DevelopmentCount => Developments.Count; public static int DevelopmentCount => Developments.Count;
public static int ThreadCount => Threads.Count; public static int ThreadCount => Threads.Count;
public static int CharacterCount => Characters.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() public static void Clear()
{ {
@ -52,6 +63,9 @@ public static class NarrativeStoryStore
OpenConflictsByCharacter.Clear(); OpenConflictsByCharacter.Clear();
ConflictCastByThread.Clear(); ConflictCastByThread.Clear();
IdScratch.Clear(); IdScratch.Clear();
_nextRuntimeCompactionAt = 0f;
LastCompactionMs = 0f;
CompactionCount = 0;
NarrativeEventStore.Clear(); NarrativeEventStore.Clear();
Revision++; Revision++;
StoryScheduler.Clear(); StoryScheduler.Clear();
@ -189,7 +203,10 @@ public static class NarrativeStoryStore
} }
Developments[development.Id] = development; Developments[development.Id] = development;
CharacterStoryState subject = GetOrCreateCharacter(development.SubjectId, development.Subject); if (ShouldProjectDevelopment(development))
{
CharacterStoryState subject =
GetOrCreateCharacter(development.SubjectId, development.Subject);
TouchDevelopment(subject, development); TouchDevelopment(subject, development);
if (development.OtherId != 0) if (development.OtherId != 0)
{ {
@ -197,6 +214,8 @@ public static class NarrativeStoryStore
} }
NarrativeThreadRules.Apply(development); NarrativeThreadRules.Apply(development);
}
MaybeCompactRuntimeGraph();
Revision++; Revision++;
return true; return true;
} }
@ -236,6 +255,7 @@ public static class NarrativeStoryStore
thread.UpdatedAt = development.OccurredAt; thread.UpdatedAt = development.OccurredAt;
thread.LatestMeaningfulChangeId = development.Id; thread.LatestMeaningfulChangeId = development.Id;
if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id); if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id);
TrimThreadDevelopmentHistory(thread);
thread.AddCast(protagonistId, "protagonist"); thread.AddCast(protagonistId, "protagonist");
if (development.OtherId != 0) thread.AddCast(development.OtherId, CastRole(development.Kind)); if (development.OtherId != 0) thread.AddCast(development.OtherId, CastRole(development.Kind));
thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function)); thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function));
@ -252,6 +272,18 @@ public static class NarrativeStoryStore
return thread; 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) public static void AddConsequence(CharacterConsequence consequence)
{ {
if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return; if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return;
@ -494,6 +526,7 @@ public static class NarrativeStoryStore
int repaired = 0; int repaired = 0;
foreach (NarrativeThread thread in Threads.Values) foreach (NarrativeThread thread in Threads.Values)
{ {
TrimThreadDevelopmentHistory(thread);
if (thread != null if (thread != null
&& thread.Kind == NarrativeThreadKind.Founding && thread.Kind == NarrativeThreadKind.Founding
&& thread.IsOpen && thread.IsOpen
@ -559,6 +592,398 @@ public static class NarrativeStoryStore
return repaired; 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>
/// Birth evidence remains in the event/development stores for persistence and dedupe.
/// A full mirrored lineage graph is useful only once the family touches the authored cast;
/// projecting every anonymous newborn was creating hundreds of disposable threads per minute.
/// </summary>
private static bool ShouldProjectDevelopment(NarrativeDevelopment development)
{
if (development == null
|| development.Kind != NarrativeDevelopmentKind.ChildBorn
|| (!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));
}
/// <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) public static bool HarnessProbeCombatLifecycle(out string detail)
{ {
Clear(); Clear();
@ -697,6 +1122,121 @@ public static class NarrativeStoryStore
return ok; 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
});
bool ambientBirthDeferred = 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;
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 = ambientBirthDeferred && authoredBirthProjected
&& 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
+ " birth=" + ambientBirthDeferred + "/" + authoredBirthProjected
+ " ms=" + LastCompactionMs.ToString("0.0")
+ " pass=" + ok;
Clear();
return ok;
}
private static void AddSchedulerCopy(List<NarrativeThread> into, NarrativeThread thread) private static void AddSchedulerCopy(List<NarrativeThread> into, NarrativeThread thread)
{ {
if (thread == null || into.Count >= SchedulerCopyLimit if (thread == null || into.Count >= SchedulerCopyLimit
@ -852,6 +1392,7 @@ public static class NarrativeStoryStore
{ {
thread.DevelopmentIds.Add(terminal.Id); thread.DevelopmentIds.Add(terminal.Id);
} }
TrimThreadDevelopmentHistory(thread);
} }
if (Characters.TryGetValue(thread.ProtagonistId, out CharacterStoryState state)) if (Characters.TryGetValue(thread.ProtagonistId, out CharacterStoryState state))

View file

@ -841,7 +841,7 @@ public sealed class UnitDossier
} }
int start = i; int start = i;
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_' || text[i] == '-')) while (i < text.Length && IsNameTokenChar(text[i]))
{ {
i++; i++;
} }
@ -1016,9 +1016,9 @@ public sealed class UnitDossier
return false; return false;
} }
bool leftOk = idx == 0 || !char.IsLetterOrDigit(reason[idx - 1]); bool leftOk = idx == 0 || !IsNameTokenChar(reason[idx - 1]);
int end = idx + name.Length; 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) if (leftOk && rightOk)
{ {
return true; return true;
@ -1030,6 +1030,37 @@ public sealed class UnitDossier
return false; 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) private static string EventLabelBeat(string label, UnitDossier d, bool recordDrops = true)
{ {
if (string.IsNullOrEmpty(label)) if (string.IsNullOrEmpty(label))

View file

@ -120,6 +120,7 @@ public static class WatchCaption
private static bool _dossierRowsNeedRestore; private static bool _dossierRowsNeedRestore;
private static float _nextPortraitAt; private static float _nextPortraitAt;
private static long _portraitActorId; private static long _portraitActorId;
private const float PortraitRefreshSeconds = 1f;
private static string _statusBanner = ""; private static string _statusBanner = "";
private static float _statusBannerUntil; private static float _statusBannerUntil;
private static string _lastLoggedCaption = ""; private static string _lastLoggedCaption = "";
@ -921,7 +922,8 @@ public static class WatchCaption
long id = EventFeedUtil.SafeId(actor); long id = EventFeedUtil.SafeId(actor);
float now = Time.unscaledTime; 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. // Focus change always applies immediately.
if (id == _portraitActorId && now < _nextPortraitAt) if (id == _portraitActorId && now < _nextPortraitAt)
{ {
@ -929,7 +931,7 @@ public static class WatchCaption
} }
_portraitActorId = id; _portraitActorId = id;
_nextPortraitAt = now + 0.2f; _nextPortraitAt = now + PortraitRefreshSeconds;
DossierAvatar.Show(actor); DossierAvatar.Show(actor);
BringHeaderFront(); BringHeaderFront();
} }