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
This commit is contained in:
DazedAnon 2026-07-23 14:52:27 -05:00
parent ff4b40d00e
commit fcf2445cd0
11 changed files with 660 additions and 43 deletions

View file

@ -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;

View file

@ -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);
}
/// <summary>
/// Publish reason telemetry only after camera focus and caption ownership have committed.
/// This makes Watching/focus/caption one observable transition rather than three frames.
/// </summary>
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)

View file

@ -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)

View file

@ -18,6 +18,10 @@ public sealed class LiveSceneStickyState
public bool HasPresentedScale;
public EnsembleScale PresentedScale;
public float ScaleChangeSince = -999f;
/// <summary>Last structurally committed combat reason; separate from live headcounts.</summary>
public string PresentedReason = "";
public string PendingReason = "";
public float ReasonChangeSince = -999f;
/// <summary>
/// After a camp hits 0, stay in mop-up until the thin side recovers to
/// <see cref="StickyScoreboard.WipeRecoverMin"/> (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;

View file

@ -232,6 +232,104 @@ namespace IdleSpectator;
}
}
/// <summary>
/// Hold structural Duel/Skirmish/Battle/Mass wording across transient probe changes.
/// Headcount and same-theater reframes remain live without becoming new story beats.
/// </summary>
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;
}
/// <summary>
/// Lock opposing camps once (when multi), enroll members, rewrite ensemble sides/counts.
/// </summary>

View file

@ -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;
}
/// <summary>
/// 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.
/// </summary>
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)

View file

@ -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")
};

View file

@ -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

View file

@ -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)

View file

@ -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))

View file

@ -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<string, float> ContextShownAt =
new Dictionary<string, float>(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;
}
/// <summary>
/// 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.
/// </summary>
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<string>();
foreach (KeyValuePair<string, float> 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;
}
/// <summary>
/// Soft live tips (love/rest/dream) should not hitch closed biography
/// (kills, crowning, founding). Ongoing pressure (war/plot/rival/grief) still may.