diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 4879def..44b6da4 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -2938,6 +2938,26 @@ public static class AgentHarness
break;
}
+ case "narrative_iteration_probe":
+ {
+ bool contextOk = CaptionComposer.HarnessProbeContextPolicy(out string context);
+ bool combatOk = StickyScoreboard.HarnessProbeReasonStability(out string combat);
+ bool participantOk =
+ NarrativePresentationFactory.HarnessProbeNamedParticipantFallback(
+ out string participant);
+ bool proseOk = EventReason.HarnessProbePresentationProse(out string prose);
+ bool persistenceOk = NarrativePersistence.HarnessRoundTrip(out string persistence);
+ bool ok = contextOk && combatOk && participantOk && proseOk && persistenceOk;
+ if (ok) _cmdOk++; else _cmdFail++;
+ Emit(
+ cmd,
+ ok,
+ detail: "context={" + context + "} combat={" + combat
+ + "} participant={" + participant + "} prose={" + prose
+ + "} persistence={" + persistence + "}");
+ break;
+ }
+
case "narrative_presentation_coverage_probe":
{
Actor subject = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs
index f7a9f1a..62b087d 100644
--- a/IdleSpectator/CameraDirector.cs
+++ b/IdleSpectator/CameraDirector.cs
@@ -34,25 +34,6 @@ public static class CameraDirector
return;
}
- string tip = FormatWatchTip(interest);
- string label = interest.Label ?? "";
- string assetId = interest.AssetId ?? "";
- // AssetId-only churn (live_combat ↔ live_battle) must not re-log the same tip.
- bool tipChanged = !string.Equals(tip, LastFormattedWatchTip, StringComparison.Ordinal)
- || !string.Equals(label, LastWatchLabel, StringComparison.Ordinal);
- bool framingOnly = tipChanged
- && (EventReason.IsHeadcountOnlyChange(LastWatchLabel, label)
- || EventReason.IsCombatFramingOnlyChange(LastWatchLabel, label));
- LastFormattedWatchTip = tip;
- // LastWatchLabel is tip/harness telemetry only - never dossier reason truth.
- LastWatchLabel = label;
- LastWatchAssetId = assetId;
- // Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only.
- if (tipChanged && !framingOnly)
- {
- LogService.LogInfo($"[IdleSpectator] {tip}");
- }
-
Actor follow = ResolveFollowUnit(interest);
if (follow != null && follow.isAlive())
{
@@ -68,10 +49,11 @@ public static class CameraDirector
WatchCaption.SetFromActor(follow);
}
+ CommitWatchTelemetry(interest);
return;
}
- // No accepted living follow: tip already logged. Do not bind a rejected FollowUnit
+ // No accepted living follow: do not bind a rejected FollowUnit
// (dead / species mismatch) as the dossier subject.
if (!interest.HasFollowUnit)
{
@@ -82,6 +64,34 @@ public static class CameraDirector
{
PanCamera(interest.Position);
}
+
+ CommitWatchTelemetry(interest);
+ }
+
+ ///
+ /// Publish reason telemetry only after camera focus and caption ownership have committed.
+ /// This makes Watching/focus/caption one observable transition rather than three frames.
+ ///
+ private static void CommitWatchTelemetry(InterestEvent interest)
+ {
+ string tip = FormatWatchTip(interest);
+ string label = interest?.Label ?? "";
+ string assetId = interest?.AssetId ?? "";
+ // AssetId-only churn (live_combat ↔ live_battle) must not re-log the same tip.
+ bool tipChanged = !string.Equals(tip, LastFormattedWatchTip, StringComparison.Ordinal)
+ || !string.Equals(label, LastWatchLabel, StringComparison.Ordinal);
+ bool framingOnly = tipChanged
+ && (EventReason.IsHeadcountOnlyChange(LastWatchLabel, label)
+ || EventReason.IsCombatFramingOnlyChange(LastWatchLabel, label));
+ LastFormattedWatchTip = tip;
+ // LastWatchLabel is tip/harness telemetry only - never dossier reason truth.
+ LastWatchLabel = label;
+ LastWatchAssetId = assetId;
+ // Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only.
+ if (tipChanged && !framingOnly)
+ {
+ LogService.LogInfo($"[IdleSpectator] {tip}");
+ }
}
private static Actor ResolveFollowUnit(InterestEvent interest)
diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs
index 85c5fb9..0dbc816 100644
--- a/IdleSpectator/Events/EventReason.cs
+++ b/IdleSpectator/Events/EventReason.cs
@@ -392,8 +392,10 @@ public static class EventReason
if (!string.IsNullOrEmpty(side.KingdomDisplay))
{
string kingdom = LiveEnsemble.KingdomDisplay(side.KingdomDisplay);
+ string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay(side.KingdomDisplay);
if (!string.IsNullOrEmpty(kingdom)
&& !kingdom.Equals(label, StringComparison.OrdinalIgnoreCase)
+ && !kingdomAsSpecies.Equals(label, StringComparison.OrdinalIgnoreCase)
&& kingdom.IndexOf(label, StringComparison.OrdinalIgnoreCase) < 0)
{
label = "[" + kingdom + "] " + label;
@@ -1066,21 +1068,79 @@ public static class EventReason
id = HumanizeId(buildingId);
}
- string what = string.IsNullOrEmpty(id)
- ? (spectacle ? "a wonder" : "a building")
- : (spectacle ? "wonder " + id : id);
+ id = NormalizeBuildingDisplay(buildingId, id);
+ string what = string.IsNullOrEmpty(id) ? "a structure" : WithIndefiniteArticle(id);
if (near != null && near.isAlive())
{
string n = Name(near);
if (!string.IsNullOrEmpty(n))
{
- return n + " finishes " + what;
+ return spectacle
+ ? n + " completes " + what
+ : n + " finishes building " + what;
}
}
- return spectacle ? "Wonder finished: " + (string.IsNullOrEmpty(id) ? "structure" : id)
- : "Building finished: " + (string.IsNullOrEmpty(id) ? "structure" : id);
+ return spectacle ? "Wonder completed: " + (string.IsNullOrEmpty(id) ? "structure" : id)
+ : "Building completed: " + (string.IsNullOrEmpty(id) ? "structure" : id);
+ }
+
+ private static string NormalizeBuildingDisplay(string buildingId, string display)
+ {
+ string raw = (buildingId ?? "").Trim().ToLowerInvariant();
+ string value = (display ?? "").Trim();
+ string[] cultureSuffixes = { "human", "dwarf", "elf", "orc" };
+ for (int i = 0; i < cultureSuffixes.Length; i++)
+ {
+ string suffix = "_" + cultureSuffixes[i];
+ if (!raw.EndsWith(suffix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ string baseId = raw.Substring(0, raw.Length - suffix.Length);
+ string fallback = HumanizeId(raw);
+ if (string.IsNullOrEmpty(value)
+ || value.Equals(fallback, StringComparison.OrdinalIgnoreCase))
+ {
+ return HumanizeId(baseId);
+ }
+ }
+
+ return value;
+ }
+
+ private static string WithIndefiniteArticle(string noun)
+ {
+ string value = (noun ?? "").Trim();
+ if (string.IsNullOrEmpty(value))
+ {
+ return "a structure";
+ }
+
+ char first = char.ToLowerInvariant(value[0]);
+ string article = first == 'a' || first == 'e' || first == 'i'
+ || first == 'o' || first == 'u'
+ ? "an "
+ : "a ";
+ return article + char.ToLowerInvariant(value[0])
+ + (value.Length > 1 ? value.Substring(1) : "");
+ }
+
+ public static bool HarnessProbePresentationProse(out string detail)
+ {
+ string building = BuildingComplete(null, "tent_human", spectacle: false);
+ bool buildingClean = building == "Building completed: Tent"
+ && building.IndexOf("Human", StringComparison.OrdinalIgnoreCase) < 0;
+ 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
+ + "' kingdomSpecies='" + kingdomAsSpecies + "' pass=" + ok;
+ return ok;
}
public static string Decision(Actor a, string decisionId)
diff --git a/IdleSpectator/Events/LiveSceneStickyState.cs b/IdleSpectator/Events/LiveSceneStickyState.cs
index 60d6e0e..2507629 100644
--- a/IdleSpectator/Events/LiveSceneStickyState.cs
+++ b/IdleSpectator/Events/LiveSceneStickyState.cs
@@ -18,6 +18,10 @@ public sealed class LiveSceneStickyState
public bool HasPresentedScale;
public EnsembleScale PresentedScale;
public float ScaleChangeSince = -999f;
+ /// Last structurally committed combat reason; separate from live headcounts.
+ public string PresentedReason = "";
+ public string PendingReason = "";
+ public float ReasonChangeSince = -999f;
///
/// After a camp hits 0, stay in mop-up until the thin side recovers to
/// (stops 0↔1 Mass↔Battle thrash).
@@ -60,6 +64,9 @@ public sealed class LiveSceneStickyState
HasPresentedScale = false;
PresentedScale = EnsembleScale.Pair;
ScaleChangeSince = -999f;
+ PresentedReason = "";
+ PendingReason = "";
+ ReasonChangeSince = -999f;
MopUpActive = false;
MopUpSince = -999f;
SideAKey = "";
@@ -89,6 +96,9 @@ public sealed class LiveSceneStickyState
other.HasPresentedScale = HasPresentedScale;
other.PresentedScale = PresentedScale;
other.ScaleChangeSince = ScaleChangeSince;
+ other.PresentedReason = PresentedReason;
+ other.PendingReason = PendingReason;
+ other.ReasonChangeSince = ReasonChangeSince;
other.MopUpActive = MopUpActive;
other.MopUpSince = MopUpSince;
other.SideAKey = SideAKey;
diff --git a/IdleSpectator/Events/StickyScoreboard.cs b/IdleSpectator/Events/StickyScoreboard.cs
index aa36bae..e4275df 100644
--- a/IdleSpectator/Events/StickyScoreboard.cs
+++ b/IdleSpectator/Events/StickyScoreboard.cs
@@ -232,6 +232,104 @@ namespace IdleSpectator;
}
}
+ ///
+ /// Hold structural Duel/Skirmish/Battle/Mass wording across transient probe changes.
+ /// Headcount and same-theater reframes remain live without becoming new story beats.
+ ///
+ public static string StabilizeCombatReason(
+ LiveSceneStickyState sticky,
+ string current,
+ string proposed,
+ float now,
+ bool allowImmediate)
+ {
+ if (sticky == null || string.IsNullOrEmpty(proposed))
+ {
+ return string.IsNullOrEmpty(proposed) ? (current ?? "") : proposed;
+ }
+
+ string prior = !string.IsNullOrEmpty(sticky.PresentedReason)
+ ? sticky.PresentedReason
+ : (current ?? "");
+ if (string.IsNullOrEmpty(prior)
+ || !IsCombatReason(prior)
+ || !IsCombatReason(proposed)
+ || allowImmediate)
+ {
+ sticky.PresentedReason = proposed;
+ sticky.PendingReason = "";
+ sticky.ReasonChangeSince = -999f;
+ return proposed;
+ }
+
+ if (string.Equals(prior, proposed, StringComparison.Ordinal))
+ {
+ sticky.PendingReason = "";
+ sticky.ReasonChangeSince = -999f;
+ return proposed;
+ }
+
+ if (EventReason.IsHeadcountOnlyChange(prior, proposed)
+ || EventReason.IsCombatFramingOnlyChange(prior, proposed))
+ {
+ sticky.PresentedReason = proposed;
+ sticky.PendingReason = "";
+ sticky.ReasonChangeSince = -999f;
+ return proposed;
+ }
+
+ if (!string.Equals(sticky.PendingReason, proposed, StringComparison.Ordinal))
+ {
+ sticky.PendingReason = proposed;
+ sticky.ReasonChangeSince = now;
+ return prior;
+ }
+
+ if (now - sticky.ReasonChangeSince < ScaleHoldSeconds)
+ {
+ return prior;
+ }
+
+ sticky.PresentedReason = proposed;
+ sticky.PendingReason = "";
+ sticky.ReasonChangeSince = -999f;
+ return proposed;
+ }
+
+ private static bool IsCombatReason(string label)
+ {
+ string value = (label ?? "").Trim();
+ return value.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
+ || value.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
+ || value.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
+ || value.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
+ || value.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
+ public static bool HarnessProbeReasonStability(out string detail)
+ {
+ var sticky = new LiveSceneStickyState();
+ const string duel = "Duel - Aro vs Bex";
+ const string battle = "Battle - Humans (4) vs Orcs (3)";
+ const string battleTick = "Battle - Humans (3) vs Orcs (4)";
+ string first = StabilizeCombatReason(sticky, duel, battle, 10f, false);
+ string held = StabilizeCombatReason(sticky, first, battle, 11f, false);
+ string committed = StabilizeCombatReason(sticky, held, battle, 13f, false);
+ string headcount = StabilizeCombatReason(sticky, committed, battleTick, 13.1f, false);
+
+ sticky.Clear();
+ string immediate = StabilizeCombatReason(sticky, duel, battle, 20f, true);
+ bool ok = first == duel
+ && held == duel
+ && committed == battle
+ && headcount == battleTick
+ && immediate == battle;
+ detail = "first='" + first + "' held='" + held
+ + "' committed='" + committed + "' headcount='" + headcount
+ + "' immediate='" + immediate + "' pass=" + ok;
+ return ok;
+ }
+
///
/// Lock opposing camps once (when multi), enroll members, rewrite ensemble sides/counts.
///
diff --git a/IdleSpectator/HappinessEventRouter.cs b/IdleSpectator/HappinessEventRouter.cs
index 465ee20..923a192 100644
--- a/IdleSpectator/HappinessEventRouter.cs
+++ b/IdleSpectator/HappinessEventRouter.cs
@@ -257,6 +257,7 @@ public static class HappinessEventRouter
if (ShouldMerge(occ, entry))
{
occ.Merged = true;
+ EnrichEarlierOccurrence(occ);
if (_counterSubjectId == 0 || occ.SubjectId == _counterSubjectId)
{
_mergedSinceClear++;
@@ -687,6 +688,50 @@ public static class HappinessEventRouter
return true;
}
+ ///
+ /// A bridge callback can know the named partner after the first canonical signal was
+ /// recorded. The drain stores occurrence references, so enriching the earlier survivor
+ /// preserves that participant without creating a duplicate camera beat.
+ ///
+ private static void EnrichEarlierOccurrence(HappinessOccurrence incoming)
+ {
+ if (incoming == null
+ || incoming.RelatedId == 0
+ || string.IsNullOrEmpty(incoming.RelatedName))
+ {
+ return;
+ }
+
+ for (int i = Recent.Count - 1; i >= 0 && i >= Recent.Count - 12; i--)
+ {
+ HappinessOccurrence prior = Recent[i];
+ if (prior == null
+ || prior.Merged
+ || prior.SubjectId != incoming.SubjectId
+ || !string.Equals(prior.EffectId, incoming.EffectId, StringComparison.OrdinalIgnoreCase)
+ || prior.RelatedId != 0)
+ {
+ continue;
+ }
+
+ prior.RelatedId = incoming.RelatedId;
+ prior.RelatedName = incoming.RelatedName;
+ prior.RelatedSpecies = incoming.RelatedSpecies;
+ if (prior.RelationRole == HappinessRelationRole.None)
+ {
+ prior.RelationRole = incoming.RelationRole;
+ }
+
+ if (!string.IsNullOrEmpty(incoming.PlainClause))
+ {
+ prior.PlainClause = incoming.PlainClause;
+ prior.RichClause = incoming.RichClause;
+ }
+
+ return;
+ }
+ }
+
private static void MarkAmbient(HappinessOccurrence occ)
{
if (occ.Presentation != HappinessPresentationTier.Ambient)
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 3b60a74..0e399de 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -3238,6 +3238,7 @@ internal static class HarnessScenarios
Step("npc2", "spawn", asset: "human", count: 1),
Step("npc3", "focus", asset: "human"),
Step("npc4", "narrative_presentation_coverage_probe"),
+ Step("npc4b", "narrative_iteration_probe"),
Step("npc5", "assert", expect: "no_bad"),
Step("npc99", "snapshot")
};
diff --git a/IdleSpectator/InterestDirector.StickyMaintain.cs b/IdleSpectator/InterestDirector.StickyMaintain.cs
index a54f42d..d03b46a 100644
--- a/IdleSpectator/InterestDirector.StickyMaintain.cs
+++ b/IdleSpectator/InterestDirector.StickyMaintain.cs
@@ -552,6 +552,31 @@ public static partial class InterestDirector
// Owner still fighting after first partner died - no revolving Duel names.
}
+ bool staleNamedPair = !string.IsNullOrEmpty(previousLabel)
+ && previousLabel.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
+ && (curFollow == null
+ || !curFollow.isAlive()
+ || curRelated == null
+ || !curRelated.isAlive());
+ bool promoteCollective = _current.Sticky.HasOpposingSides
+ && _current.Sticky.TotalCount >= 3
+ && (previousLabel.StartsWith(
+ "Duel", StringComparison.OrdinalIgnoreCase)
+ || previousLabel.IndexOf(
+ " is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
+ && ((label ?? "").StartsWith(
+ "Skirmish", StringComparison.OrdinalIgnoreCase)
+ || (label ?? "").StartsWith(
+ "Battle", StringComparison.OrdinalIgnoreCase)
+ || (label ?? "").StartsWith(
+ "Mass", StringComparison.OrdinalIgnoreCase));
+ label = StickyScoreboard.StabilizeCombatReason(
+ _current.Sticky,
+ previousLabel,
+ label,
+ Time.unscaledTime,
+ staleNamedPair || promoteCollective);
+
bool followChanged = _current.FollowUnit != best;
bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal);
bool framingOnly = labelChanged
diff --git a/IdleSpectator/Narrative/NarrativePersistence.cs b/IdleSpectator/Narrative/NarrativePersistence.cs
index 7d02d85..aafcfe5 100644
--- a/IdleSpectator/Narrative/NarrativePersistence.cs
+++ b/IdleSpectator/Narrative/NarrativePersistence.cs
@@ -100,14 +100,17 @@ public static class NarrativePersistence
try
{
NarrativeSaveState state = Capture(worldKey);
- string json = JsonConvert.SerializeObject(state, Formatting.Indented);
+ // Sidecars are machine-owned. Compact JSON cuts write size substantially without
+ // discarding a single event, development, thread, consequence, character, or fact.
+ string json = JsonConvert.SerializeObject(state, Formatting.None);
string path = PathFor(worldKey);
WriteAtomic(path, json);
_dirty = false;
LastPath = path;
LastStatus = "saved events=" + state.events.Count
+ " developments=" + state.developments.Count
- + " facts=" + state.facts.Count;
+ + " facts=" + state.facts.Count
+ + " bytes=" + json.Length;
LogService.LogInfo("[IdleSpectator] Narrative persistence " + LastStatus);
}
catch (Exception ex)
diff --git a/IdleSpectator/Narrative/NarrativePresentation.cs b/IdleSpectator/Narrative/NarrativePresentation.cs
index 21dfb44..8cf5d2a 100644
--- a/IdleSpectator/Narrative/NarrativePresentation.cs
+++ b/IdleSpectator/Narrative/NarrativePresentation.cs
@@ -100,6 +100,35 @@ public sealed class NarrativePresentationModel
public static class NarrativePresentationFactory
{
+ public static bool HarnessProbeNamedParticipantFallback(out string detail)
+ {
+ var occurrence = new HappinessOccurrence
+ {
+ EffectId = "fallen_in_love",
+ SubjectId = 11,
+ SubjectName = "Joon",
+ RelatedId = 0,
+ RelatedName = "Omyahel",
+ RelatedSpecies = "human"
+ };
+ LifeSagaIdentity other = OccurrenceOtherIdentity(occurrence, default(LifeSagaIdentity));
+ var model = new NarrativePresentationModel
+ {
+ Kind = NarrativePresentationKind.BondFormed,
+ PerspectiveId = occurrence.SubjectId,
+ Perspective = new LifeSagaIdentity { Id = 11, Name = "Joon" },
+ OtherId = other.Id,
+ Other = other
+ };
+ string beat = NarrativeRenderer.Beat(model);
+ bool ok = other.Id == 0
+ && other.Name == "Omyahel"
+ && beat == "Finds love with Omyahel";
+ detail = "other=" + other.Id + ":" + other.Name
+ + " beat='" + beat + "' pass=" + ok;
+ return ok;
+ }
+
public static NarrativePresentationModel Relationship(string eventId, Actor subject, Actor related)
{
if (subject == null)
@@ -275,16 +304,8 @@ public static class NarrativePresentationFactory
}
long perspectiveId = EventFeedUtil.SafeId(subject);
- LifeSagaIdentity otherSnapshot = LifeSagaMemory.Snapshot(related);
- if (otherSnapshot.Id == 0 && occurrence.RelatedId != 0)
- {
- otherSnapshot = new LifeSagaIdentity
- {
- Id = occurrence.RelatedId,
- Name = occurrence.RelatedName ?? "",
- SpeciesId = occurrence.RelatedSpecies ?? ""
- };
- }
+ LifeSagaIdentity otherSnapshot =
+ OccurrenceOtherIdentity(occurrence, LifeSagaMemory.Snapshot(related));
NarrativeDevelopment development = developmentKind == NarrativeDevelopmentKind.Unknown
? null
@@ -394,6 +415,28 @@ public static class NarrativePresentationFactory
return development;
}
+ private static LifeSagaIdentity OccurrenceOtherIdentity(
+ HappinessOccurrence occurrence,
+ LifeSagaIdentity live)
+ {
+ if (live.Id != 0 || occurrence == null)
+ {
+ return live;
+ }
+
+ if (occurrence.RelatedId == 0 && string.IsNullOrEmpty(occurrence.RelatedName))
+ {
+ return live;
+ }
+
+ return new LifeSagaIdentity
+ {
+ Id = occurrence.RelatedId,
+ Name = occurrence.RelatedName ?? "",
+ SpeciesId = occurrence.RelatedSpecies ?? ""
+ };
+ }
+
private static Actor FirstParent(Actor child)
{
foreach (Actor parent in ActorRelation.EnumerateParents(child, 1))
diff --git a/IdleSpectator/Story/CaptionComposer.cs b/IdleSpectator/Story/CaptionComposer.cs
index ff79049..9e0dfe4 100644
--- a/IdleSpectator/Story/CaptionComposer.cs
+++ b/IdleSpectator/Story/CaptionComposer.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
@@ -10,12 +11,17 @@ namespace IdleSpectator;
public static class CaptionComposer
{
public const float ReentrySeconds = 12f;
+ public const float ContextRepeatCooldownSeconds = 75f;
private static long _lastCaptionUnitId;
private static float _lastCaptionAt = -999f;
private static float _lastCutawayAt = -999f;
private static long _cutawayFromId;
private static string _lastFingerprint = "";
+ private static readonly Dictionary ContextShownAt =
+ new Dictionary(64);
+ private static string _activeContextKey = "";
+ private static string _activeContextBeatKey = "";
public static string LastIdentityLine { get; private set; } = "";
public static string LastBeatLine { get; private set; } = "";
@@ -30,6 +36,9 @@ public static class CaptionComposer
_lastCutawayAt = -999f;
_cutawayFromId = 0;
_lastFingerprint = "";
+ ContextShownAt.Clear();
+ _activeContextKey = "";
+ _activeContextBeatKey = "";
LastIdentityLine = "";
LastBeatLine = "";
LastContextLine = "";
@@ -320,25 +329,35 @@ public static class CaptionComposer
string tipMood = TipMood(beatLine, tip);
// Walk strongest → weaker so a soft tip can skip a kill/role hitch and still
// show an ongoing war/plot/rival stake when one exists.
- LifeSagaFact stake = PickHitchStake(focusId, beatLine, tipMood, identityLine ?? "");
+ LifeSagaFact stake = PickHitchStake(
+ focusId,
+ tip,
+ beatLine,
+ tipMood,
+ identityLine ?? "",
+ isReentry);
if (stake == null)
{
return "";
}
- return SagaProse.StakeLine(stake) ?? "";
+ string line = SagaProse.StakeLine(stake) ?? "";
+ return AdmitContextLine(focusId, tip, beatLine, stake, line);
}
private static LifeSagaFact PickHitchStake(
long focusId,
+ InterestCandidate tip,
string beatLine,
string tipMood,
- string identityLine)
+ string identityLine,
+ bool isReentry)
{
LifeSagaFact best = LifeSagaMemory.StrongestUnresolved(focusId);
if (best != null
&& !BeatCoversStake(beatLine, best)
- && !ShouldOmitStakeHitch(best, tipMood, identityLine))
+ && !ShouldOmitStakeHitch(best, tipMood, identityLine)
+ && StakeBelongsToBeat(best, tip, tipMood, isReentry))
{
return best;
}
@@ -353,7 +372,9 @@ public static class CaptionComposer
continue;
}
- if (BeatCoversStake(beatLine, f) || ShouldOmitStakeHitch(f, tipMood, identityLine))
+ if (BeatCoversStake(beatLine, f)
+ || ShouldOmitStakeHitch(f, tipMood, identityLine)
+ || !StakeBelongsToBeat(f, tip, tipMood, isReentry))
{
continue;
}
@@ -371,6 +392,287 @@ public static class CaptionComposer
return next;
}
+ ///
+ /// Context is connective tissue, not a random biography card. Reentry may remind the
+ /// viewer of the strongest open stake; ordinary cuts require semantic alignment with the
+ /// current beat or an actually ongoing pressure arc.
+ ///
+ private static bool StakeBelongsToBeat(
+ LifeSagaFact stake,
+ InterestCandidate tip,
+ string tipMood,
+ bool isReentry)
+ {
+ if (stake == null)
+ {
+ return false;
+ }
+
+ if (isReentry)
+ {
+ return true;
+ }
+
+ NarrativeDevelopmentKind developmentKind =
+ tip?.NarrativePresentation?.DevelopmentKind ?? NarrativeDevelopmentKind.Unknown;
+ if (developmentKind != NarrativeDevelopmentKind.Unknown)
+ {
+ if (!FactMatchesDevelopment(stake.Kind, developmentKind))
+ {
+ return false;
+ }
+
+ long otherId = tip.NarrativePresentation.OtherId != 0
+ ? tip.NarrativePresentation.OtherId
+ : tip.RelatedId;
+ return otherId == 0 || stake.OtherId == 0 || otherId == stake.OtherId;
+ }
+
+ switch (tipMood)
+ {
+ case "love":
+ return IsLoveStake(stake.Kind);
+ case "grief":
+ return stake.Kind == LifeSagaFactKind.BondBroken
+ || stake.Kind == LifeSagaFactKind.Death
+ || stake.Kind == LifeSagaFactKind.HardArcGrief;
+ case "combat":
+ return stake.Kind == LifeSagaFactKind.CombatEncounter
+ || stake.Kind == LifeSagaFactKind.RivalEarned
+ || stake.Kind == LifeSagaFactKind.HardArcCombat
+ || stake.Kind == LifeSagaFactKind.WarJoin
+ || stake.Kind == LifeSagaFactKind.HardArcWar;
+ case "plot":
+ return stake.Kind == LifeSagaFactKind.PlotJoin
+ || stake.Kind == LifeSagaFactKind.HardArcPlot;
+ case "rest":
+ return false;
+ }
+
+ NarrativeFunction function = tip != null
+ ? NarrativeFunctionPolicy.Classify(tip)
+ : NarrativeFunction.Noise;
+ if (!NarrativeFunctionPolicy.ChangesStoryState(function))
+ {
+ return false;
+ }
+
+ if (tip != null)
+ {
+ switch (tip.Completion)
+ {
+ case InterestCompletionKind.CombatActive:
+ return stake.Kind == LifeSagaFactKind.CombatEncounter
+ || stake.Kind == LifeSagaFactKind.RivalEarned
+ || stake.Kind == LifeSagaFactKind.HardArcCombat;
+ case InterestCompletionKind.WarFront:
+ return stake.Kind == LifeSagaFactKind.WarJoin
+ || stake.Kind == LifeSagaFactKind.HardArcWar;
+ case InterestCompletionKind.PlotActive:
+ return stake.Kind == LifeSagaFactKind.PlotJoin
+ || stake.Kind == LifeSagaFactKind.HardArcPlot;
+ case InterestCompletionKind.HappinessGrief:
+ return stake.Kind == LifeSagaFactKind.BondBroken
+ || stake.Kind == LifeSagaFactKind.Death
+ || stake.Kind == LifeSagaFactKind.HardArcGrief;
+ }
+ }
+
+ // A durable but otherwise unclassified beat may carry an open pressure arc. Closed
+ // résumé facts (founded, slew, became) are deliberately excluded.
+ return stake.Kind == LifeSagaFactKind.RivalEarned
+ || stake.Kind == LifeSagaFactKind.HardArcLove
+ || stake.Kind == LifeSagaFactKind.HardArcGrief
+ || stake.Kind == LifeSagaFactKind.HardArcCombat
+ || stake.Kind == LifeSagaFactKind.HardArcWar
+ || stake.Kind == LifeSagaFactKind.HardArcPlot;
+ }
+
+ private static bool FactMatchesDevelopment(
+ LifeSagaFactKind fact,
+ NarrativeDevelopmentKind development)
+ {
+ switch (development)
+ {
+ case NarrativeDevelopmentKind.BondFormed:
+ return fact == LifeSagaFactKind.BondFormed
+ || fact == LifeSagaFactKind.HardArcLove;
+ case NarrativeDevelopmentKind.BondBroken:
+ return fact == LifeSagaFactKind.BondBroken
+ || fact == LifeSagaFactKind.HardArcGrief;
+ case NarrativeDevelopmentKind.ChildBorn:
+ return fact == LifeSagaFactKind.ParentChild;
+ case NarrativeDevelopmentKind.FriendFormed:
+ return fact == LifeSagaFactKind.FriendFormed;
+ case NarrativeDevelopmentKind.Death:
+ return fact == LifeSagaFactKind.Death
+ || fact == LifeSagaFactKind.HardArcGrief;
+ case NarrativeDevelopmentKind.Kill:
+ return fact == LifeSagaFactKind.Kill;
+ case NarrativeDevelopmentKind.RoleGained:
+ case NarrativeDevelopmentKind.RoleLost:
+ return fact == LifeSagaFactKind.RoleChange;
+ case NarrativeDevelopmentKind.HomeFounded:
+ return fact == LifeSagaFactKind.Founding;
+ case NarrativeDevelopmentKind.HomeGained:
+ return fact == LifeSagaFactKind.HomeGained;
+ case NarrativeDevelopmentKind.HomeLost:
+ return fact == LifeSagaFactKind.HomeLost;
+ case NarrativeDevelopmentKind.WarJoined:
+ case NarrativeDevelopmentKind.WarEnded:
+ return fact == LifeSagaFactKind.WarJoin
+ || fact == LifeSagaFactKind.WarEnd
+ || fact == LifeSagaFactKind.HardArcWar;
+ case NarrativeDevelopmentKind.PlotJoined:
+ case NarrativeDevelopmentKind.PlotEnded:
+ return fact == LifeSagaFactKind.PlotJoin
+ || fact == LifeSagaFactKind.PlotEnd
+ || fact == LifeSagaFactKind.HardArcPlot;
+ case NarrativeDevelopmentKind.RivalEncounter:
+ return fact == LifeSagaFactKind.CombatEncounter
+ || fact == LifeSagaFactKind.RivalEarned
+ || fact == LifeSagaFactKind.HardArcCombat;
+ default:
+ return false;
+ }
+ }
+
+ private static bool IsLoveStake(LifeSagaFactKind kind)
+ {
+ return kind == LifeSagaFactKind.BondFormed
+ || kind == LifeSagaFactKind.BondBroken
+ || kind == LifeSagaFactKind.ParentChild
+ || kind == LifeSagaFactKind.HardArcLove;
+ }
+
+ private static string AdmitContextLine(
+ long focusId,
+ InterestCandidate tip,
+ string beatLine,
+ LifeSagaFact stake,
+ string line)
+ {
+ if (string.IsNullOrEmpty(line) || stake == null)
+ {
+ return "";
+ }
+
+ string stakeKey = focusId + "|"
+ + (!string.IsNullOrEmpty(stake.Key)
+ ? stake.Key
+ : stake.Kind + ":" + stake.OtherId);
+ string beatKey = focusId + "|" + (tip?.Key ?? "") + "|" + (beatLine ?? "");
+ float now = Time.time;
+
+ // Recomposition of the same visible beat must keep its context instead of blinking.
+ if (string.Equals(_activeContextKey, stakeKey, StringComparison.Ordinal)
+ && string.Equals(_activeContextBeatKey, beatKey, StringComparison.Ordinal))
+ {
+ return line;
+ }
+
+ if (ContextShownAt.TryGetValue(stakeKey, out float shownAt)
+ && now - shownAt < ContextRepeatCooldownSeconds)
+ {
+ _activeContextKey = "";
+ _activeContextBeatKey = "";
+ return "";
+ }
+
+ ContextShownAt[stakeKey] = now;
+ _activeContextKey = stakeKey;
+ _activeContextBeatKey = beatKey;
+ if (ContextShownAt.Count > 128)
+ {
+ PruneContextLedger(now);
+ }
+
+ return line;
+ }
+
+ private static void PruneContextLedger(float now)
+ {
+ var expired = new List();
+ foreach (KeyValuePair entry in ContextShownAt)
+ {
+ if (now - entry.Value >= ContextRepeatCooldownSeconds * 2f)
+ {
+ expired.Add(entry.Key);
+ }
+ }
+
+ for (int i = 0; i < expired.Count; i++)
+ {
+ ContextShownAt.Remove(expired[i]);
+ }
+ }
+
+ public static bool HarnessProbeContextPolicy(out string detail)
+ {
+ var founding = new LifeSagaFact
+ {
+ Kind = LifeSagaFactKind.Founding,
+ OtherId = 0
+ };
+ var bond = new LifeSagaFact
+ {
+ Kind = LifeSagaFactKind.BondFormed,
+ OtherId = 22
+ };
+ var child = new LifeSagaFact
+ {
+ Kind = LifeSagaFactKind.ParentChild,
+ OtherId = 33
+ };
+ var rival = new LifeSagaFact
+ {
+ Kind = LifeSagaFactKind.RivalEarned,
+ OtherId = 44
+ };
+ var texture = new InterestCandidate
+ {
+ HasNarrativeFunction = true,
+ NarrativeFunction = NarrativeFunction.CharacterTexture
+ };
+ var childBeat = new InterestCandidate
+ {
+ HasNarrativeFunction = true,
+ NarrativeFunction = NarrativeFunction.Consequence,
+ RelatedId = 33,
+ NarrativePresentation = new NarrativePresentationModel
+ {
+ DevelopmentKind = NarrativeDevelopmentKind.ChildBorn,
+ OtherId = 33
+ }
+ };
+ var combat = new InterestCandidate
+ {
+ HasNarrativeFunction = true,
+ NarrativeFunction = NarrativeFunction.Escalation,
+ Completion = InterestCompletionKind.CombatActive
+ };
+
+ bool textureRejectsResume = !StakeBelongsToBeat(founding, texture, "", false);
+ bool childMatchesChild = StakeBelongsToBeat(child, childBeat, "love", false);
+ bool childRejectsBond = !StakeBelongsToBeat(bond, childBeat, "love", false);
+ bool combatMatchesRival = StakeBelongsToBeat(rival, combat, "combat", false);
+ bool reentryRecalls = StakeBelongsToBeat(founding, texture, "", true);
+ bool restRejectsResume = !StakeBelongsToBeat(founding, texture, "rest", false);
+ bool ok = textureRejectsResume
+ && childMatchesChild
+ && childRejectsBond
+ && combatMatchesRival
+ && reentryRecalls
+ && restRejectsResume;
+ detail = "texture=" + textureRejectsResume
+ + " child=" + childMatchesChild + "/" + childRejectsBond
+ + " combat=" + combatMatchesRival
+ + " reentry=" + reentryRecalls
+ + " rest=" + restRejectsResume
+ + " pass=" + ok;
+ return ok;
+ }
+
///
/// Soft live tips (love/rest/dream) should not hitch closed biography
/// (kills, crowning, founding). Ongoing pressure (war/plot/rival/grief) still may.