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:
parent
ff4b40d00e
commit
fcf2445cd0
11 changed files with 660 additions and 43 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue