From fcf2445cd08afec21b64957a060a134250eadf52 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 23 Jul 2026 14:52:27 -0500 Subject: [PATCH] fix(narrative): improve caption relevance and combat continuity - Limit saga context to relevant beats and suppress repeated facts - Stabilize combat labels and commit telemetry after focus changes - Preserve relationship participants and normalize event prose - Compact narrative sidecars and extend presentation coverage --- IdleSpectator/AgentHarness.cs | 20 ++ IdleSpectator/CameraDirector.cs | 50 +-- IdleSpectator/Events/EventReason.cs | 72 +++- IdleSpectator/Events/LiveSceneStickyState.cs | 10 + IdleSpectator/Events/StickyScoreboard.cs | 98 ++++++ IdleSpectator/HappinessEventRouter.cs | 45 +++ IdleSpectator/HarnessScenarios.cs | 1 + .../InterestDirector.StickyMaintain.cs | 25 ++ .../Narrative/NarrativePersistence.cs | 7 +- .../Narrative/NarrativePresentation.cs | 63 +++- IdleSpectator/Story/CaptionComposer.cs | 312 +++++++++++++++++- 11 files changed, 660 insertions(+), 43 deletions(-) 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.